From ccdfff56eaedc8bbb1b33d68114d8bb61f092229 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 06:36:34 +0000 Subject: [PATCH] refactor(components): action keys publish UIActionSchema; forwardRef renderers annotate their props (#4418, #4422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three action schema interfaces plus ActionButtonProps/ActionIconProps migrate their action keys from the @deprecated legacy ActionSchema (crud.ts) to UIActionSchema (ui-action.ts) — the type the implementations were already written against. All fifteen schema-reading forwardRef renderers annotate their render function's first parameter directly, with the pass-through index signature on the annotation rather than on the forwardRef type argument, so PropsWithoutRef no longer collapses the declared props to a bare index signature. A structural guard pins both halves. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .../action-typing-integrity-4418-4422.md | 18 + .../forwardref-props-annotation.guard.test.ts | 324 ++++++++++++++++++ .../src/renderers/action/action-bar.tsx | 65 ++-- .../src/renderers/action/action-button.tsx | 33 +- .../src/renderers/action/action-group.tsx | 24 +- .../src/renderers/action/action-icon.tsx | 26 +- .../src/renderers/action/action-menu.tsx | 24 +- .../components/src/renderers/basic/div.tsx | 7 +- .../src/renderers/basic/html-elements.tsx | 8 +- .../components/src/renderers/basic/icon.tsx | 7 +- .../src/renderers/basic/separator.tsx | 7 +- .../components/src/renderers/basic/span.tsx | 7 +- .../components/src/renderers/form/button.tsx | 7 +- .../components/src/renderers/layout/card.tsx | 7 +- .../src/renderers/layout/container.tsx | 7 +- .../src/renderers/layout/semantic.tsx | 8 +- .../components/src/renderers/layout/stack.tsx | 7 +- 17 files changed, 507 insertions(+), 79 deletions(-) create mode 100644 .changeset/action-typing-integrity-4418-4422.md create mode 100644 packages/components/src/__tests__/forwardref-props-annotation.guard.test.ts diff --git a/.changeset/action-typing-integrity-4418-4422.md b/.changeset/action-typing-integrity-4418-4422.md new file mode 100644 index 0000000000..8499930692 --- /dev/null +++ b/.changeset/action-typing-integrity-4418-4422.md @@ -0,0 +1,18 @@ +--- +'@object-ui/components': minor +--- + +The action renderers publish the modern `UIActionSchema`, and every `forwardRef` renderer's props parameter is annotated so its declared types survive + +**Breaking semantics (declared `minor` per the repo's version-alignment rule — objectui#4403 precedent — never `major`).** Six exported declarations in `@object-ui/components` change the action type they name, from the `@deprecated` legacy `ActionSchema` (`crud.ts`) to `UIActionSchema` (`ui-action.ts`): + +- `ActionBarSchema.actions`, `ActionBarSchema.systemActions` +- `ActionMenuSchema.actions` +- `ActionGroupSchema.actions` +- `ActionButtonProps.schema`, `ActionIconProps.schema` + +The two types are not interchangeable in either direction. `UIActionSchema` requires `name`, which legacy inherits as optional from `BaseSchema`; legacy pins `type: 'action'` where these renderers serve `'script' | 'url' | 'modal' | 'flow' | 'api'`; and only the modern type declares `locations`, `target`, `endpoint`, `bodyExtra`, `bodyShape` and a `variant` union containing `'primary'` — all of which the implementations already read. objectui#4417 measured four compiler errors proving the VALUES were modern while the DECLARATIONS said legacy; this moves the declarations to match, so the contract and the implementation finally agree. + +No runtime behaviour changes, and no published surface is involved: none of the six declarations is re-exported from the package index, and the sweep found zero type-checked consumers outside each declaration's own file. Metadata that renders today renders identically — the renderers read the same keys through the same paths. + +Separately, all fifteen `schema`-reading `forwardRef` renderers in the package now annotate their render function's first parameter directly, and carry the pass-through index signature on that annotation rather than on the `forwardRef` type argument. `forwardRef` routes its type argument through `PropsWithoutRef`, whose `Omit` collapses a props type carrying `[key: string]: any` down to the bare index signature — every declared property erased, silently, with `noImplicitAny` reporting clean because the `any` is supplied explicitly by the index signature. That is what hid the declaration/implementation drift above for as long as it lasted. Thirteen renderers recover a real declared type for `schema` (the two raw-tag factories keep `any`, which is what they genuinely declare), and a new structural guard, `forwardref-props-annotation.guard.test.ts`, fails on any future `forwardRef` that reintroduces either half of the trap. diff --git a/packages/components/src/__tests__/forwardref-props-annotation.guard.test.ts b/packages/components/src/__tests__/forwardref-props-annotation.guard.test.ts new file mode 100644 index 0000000000..23a1853ac5 --- /dev/null +++ b/packages/components/src/__tests__/forwardref-props-annotation.guard.test.ts @@ -0,0 +1,324 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#4422 structural guard — a `forwardRef` render function must declare + * the props it reads, and the compiler cannot tell you when it stops doing so. + * + * ## The trap + * + * `forwardRef` routes `P` through `PropsWithoutRef`, defined in + * `@types/react` as: + * + * Props extends any ? ('ref' extends keyof Props ? Omit< Props, 'ref' > : Props) : Props + * + * A string index signature puts `string` into `keyof Props`, so + * `'ref' extends keyof Props` is ALWAYS true and the `Omit` branch always runs. + * `Omit` over a type carrying a string index signature keeps only the index + * signature — every declared property is erased. The render function therefore + * receives `{ [x: string]: any }` and `schema` resolves through the index + * signature to `any`. + * + * This is worse than a missing annotation because it is SILENT. The props type + * is right there in the source, so the component reads as typed to every + * reviewer and every tool. `noImplicitAny` does not see it either: the `any` is + * supplied EXPLICITLY by the index signature, so nothing is implicit and no + * TS7006/TS7031 is raised. That is what let the action renderers declare the + * deprecated `ActionSchema` while being written against `UIActionSchema` for as + * long as they did (objectui#4418) — a contradiction that became four compiler + * errors the instant those values were given a real type. + * + * ## Why a test and not a lint rule or a compiler flag + * + * No strictness flag reports this — the hole is invisible to the compiler by + * construction, which is exactly what objectui#4422 recorded. So the invariant + * needs a structural pin, in the same ratchet style as + * `app-shell/src/no-component-any-cast.ratchet.test.ts`. + * + * ## The shape this pins + * + * forwardRef< El, { schema: XSchema; className?: string } >( + * ({ schema, className, ...props }: { schema: XSchema; className?: string; [key: string]: any }, ref) => … + * ) + * + * The index signature lives on the PARAMETER ANNOTATION and not on the type + * argument. Both halves are load-bearing: + * + * * Off the type argument, so `PropsWithoutRef` has nothing to collapse and + * the declared props survive. Note the annotation cannot simply repeat the + * type argument: once `Omit` has erased `schema`, a required `schema` in + * the annotation is a TS2345 on the render function itself. + * * On the parameter, so `...props` still collects arbitrary keys for the + * DOM / Shadcn hand-off. This is therefore NOT the "drop the index + * signature and name the pass-through props" direction (objectui#4422 + * direction 2, deferred) — no component's real prop surface is enumerated + * and no spread changes behaviour. + * + * ## If this fails + * + * Do not add the file to an allowlist, and do not delete the parameter + * annotation to make the error go away — that silently untypes every prop the + * render function reads. Annotate the render function's first parameter, and + * keep the string index signature off the `forwardRef` type argument. + * + * SCOPE — `packages/components/src`, production sources only, and only + * `forwardRef` calls whose render function reads a `schema` prop. Those are the + * registered renderers, the population the finding measured. Index signatures + * are detected SYNTACTICALLY (inline object type, or a type/interface declared + * in the same file); a props type imported from elsewhere is out of reach of a + * source scan and is not claimed to be covered. + */ + +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +// packages/components/src/__tests__ -> packages/components/src +const srcRoot = path.resolve(here, '..'); + +function collectSourceFiles(root: string): string[] { + const out: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const name = entry.name; + if (name === 'node_modules' || name === 'dist' || name === '__tests__') continue; + const full = path.join(dir, name); + if (entry.isDirectory()) walk(full); + else if (/\.tsx?$/.test(name) && !/\.(test|spec)\.tsx?$/.test(name)) out.push(full); + } + }; + if (statSync(root).isDirectory()) walk(root); + return out; +} + +/** A `forwardRef` call site, reduced to the two facts this guard judges. */ +interface Site { + file: string; + line: number; + /** The render function's first parameter carries a direct type annotation. */ + annotated: boolean; + /** The props TYPE ARGUMENT syntactically carries a string index signature. */ + indexSignatureOnTypeArg: boolean; +} + +/** Does this type node syntactically carry a string index signature? */ +function hasStringIndexSignature( + node: ts.TypeNode | undefined, + localTypes: Map, + seen = new Set(), +): boolean { + if (!node) return false; + const members = (n: ts.Node): readonly ts.TypeElement[] | undefined => + ts.isTypeLiteralNode(n) || ts.isInterfaceDeclaration(n) ? n.members : undefined; + + const scan = (n: ts.Node): boolean => { + const ms = members(n); + if (ms) { + for (const m of ms) { + if (ts.isIndexSignatureDeclaration(m)) { + const p = m.parameters[0]; + if (p?.type && p.type.kind === ts.SyntaxKind.StringKeyword) return true; + } + } + // an interface may inherit one + if (ts.isInterfaceDeclaration(n) && n.heritageClauses) { + for (const h of n.heritageClauses) { + for (const t of h.types) { + if (ts.isIdentifier(t.expression) && localTypes.has(t.expression.text)) { + const target = localTypes.get(t.expression.text)!; + if (!seen.has(t.expression.text)) { + seen.add(t.expression.text); + if (scan(target)) return true; + } + } + } + } + } + return false; + } + if (ts.isTypeAliasDeclaration(n)) return scan(n.type); + if (ts.isIntersectionTypeNode(n) || ts.isUnionTypeNode(n)) return n.types.some(scan); + if (ts.isParenthesizedTypeNode(n)) return scan(n.type); + if (ts.isTypeReferenceNode(n) && ts.isIdentifier(n.typeName)) { + const name = n.typeName.text; + if (seen.has(name)) return false; + seen.add(name); + const decl = localTypes.get(name); + return decl ? scan(decl) : false; + } + return false; + }; + return scan(node); +} + +/** Collect every `forwardRef(...)` whose render function reads a `schema` prop. */ +function collectSites(file: string): Site[] { + const text = readFileSync(file, 'utf8'); + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + + const localTypes = new Map(); + const indexDecls = (n: ts.Node): void => { + if (ts.isInterfaceDeclaration(n) || ts.isTypeAliasDeclaration(n)) localTypes.set(n.name.text, n); + ts.forEachChild(n, indexDecls); + }; + indexDecls(sf); + + const sites: Site[] = []; + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = node.expression; + const isForwardRef = + (ts.isIdentifier(callee) && callee.text === 'forwardRef') || + (ts.isPropertyAccessExpression(callee) && callee.name.text === 'forwardRef'); + if (isForwardRef) { + const render = node.arguments[0]; + if (render && (ts.isArrowFunction(render) || ts.isFunctionExpression(render))) { + const first = render.parameters[0]; + // Only judge renderers — the population objectui#4422 measured. + const readsSchema = + !!first && + ((ts.isObjectBindingPattern(first.name) && + first.name.elements.some( + e => ts.isIdentifier(e.propertyName ?? e.name) && (e.propertyName ?? e.name).getText() === 'schema', + )) || + false); + if (readsSchema) { + sites.push({ + file, + line: sf.getLineAndCharacterOfPosition(node.getStart()).line + 1, + annotated: !!first!.type, + indexSignatureOnTypeArg: hasStringIndexSignature(node.typeArguments?.[1], localTypes), + }); + } + } + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + return sites; +} + +const ALL_SITES = collectSourceFiles(srcRoot).flatMap(collectSites); +const rel = (s: Site) => `${path.relative(srcRoot, s.file)}:${s.line}`; + +describe('objectui#4422 — forwardRef renderers must annotate their props parameter', () => { + it('finds the renderer population (guards against a broken scan)', () => { + // If this collapses, the walk or the AST matcher has gone stale and the + // guard would silently pass on nothing. 15 `schema`-reading forwardRef + // renderers exist at the time of writing; the floor is deliberately loose + // so adding or removing one renderer does not fail the wrong assertion. + expect(ALL_SITES.length).toBeGreaterThanOrEqual(12); + }); + + it('detects the shapes it is meant to ban (guards against a dead matcher)', () => { + // A guard whose matcher silently stops matching is worse than no guard, so + // pin it against the exact pre-fix shapes plus the spellings that erase + // props identically. These are compiled in memory — no fixture files. + const scan = (src: string): Site[] => { + const f = path.join(srcRoot, '__inmemory__.tsx'); + const sf = ts.createSourceFile(f, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const localTypes = new Map(); + const idx = (n: ts.Node): void => { + if (ts.isInterfaceDeclaration(n) || ts.isTypeAliasDeclaration(n)) localTypes.set(n.name.text, n); + ts.forEachChild(n, idx); + }; + idx(sf); + const out: Site[] = []; + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'forwardRef') { + const render = node.arguments[0]; + if (render && ts.isArrowFunction(render)) { + const first = render.parameters[0]; + if ( + first && + ts.isObjectBindingPattern(first.name) && + first.name.elements.some(e => (e.propertyName ?? e.name).getText() === 'schema') + ) { + out.push({ + file: f, + line: 1, + annotated: !!first.type, + indexSignatureOnTypeArg: hasStringIndexSignature(node.typeArguments?.[1], localTypes), + }); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + return out; + }; + + // 1. The exact pre-fix shape: index signature on the type argument, no + // parameter annotation. This is what erased `ActionBarSchema`. + const inline = scan( + 'const C = forwardRef(({ schema, ...props }, ref) => null);', + ); + expect(inline).toHaveLength(1); + expect(inline[0].annotated).toBe(false); + expect(inline[0].indexSignatureOnTypeArg).toBe(true); + + // 2. Same defect hidden behind a named interface — the spelling that made + // objectui#4422's own file list undercount by two (action-button / + // action-icon declared theirs as `ActionButtonProps` / `ActionIconProps`). + const named = scan( + 'interface P { schema: XSchema; [key: string]: any }\n' + + 'const C = forwardRef(({ schema, ...props }, ref) => null);', + ); + expect(named).toHaveLength(1); + expect(named[0].indexSignatureOnTypeArg).toBe(true); + expect(named[0].annotated).toBe(false); + + // 3. Hidden one level further, behind a type alias and an intersection. + const aliased = scan( + 'type Pass = { [key: string]: any };\n' + + 'type P = { schema: XSchema } & Pass;\n' + + 'const C = forwardRef(({ schema }, ref) => null);', + ); + expect(aliased[0].indexSignatureOnTypeArg).toBe(true); + + // 4. And the compliant shape must read as compliant. + const fixed = scan( + 'const C = forwardRef(' + + '({ schema, className, ...props }: { schema: XSchema; className?: string; [key: string]: any }, ref) => null);', + ); + expect(fixed).toHaveLength(1); + expect(fixed[0].annotated).toBe(true); + expect(fixed[0].indexSignatureOnTypeArg).toBe(false); + + // 5. A NUMBER index signature does not trigger the collapse (`keyof` still + // excludes the string `'ref'`), so it must not be reported. + const numeric = scan( + 'const C = forwardRef(({ schema }, ref) => null);', + ); + expect(numeric[0].indexSignatureOnTypeArg).toBe(false); + }); + + it('every schema-reading forwardRef annotates its props parameter', () => { + // If this fails: annotate the render function's FIRST PARAMETER directly. + // Without it the parameter's type comes from `PropsWithoutRef` of the type + // argument, and every declared prop the render function reads is `any`. + const offenders = ALL_SITES.filter(s => !s.annotated).map(rel); + expect(offenders).toEqual([]); + }); + + it('no schema-reading forwardRef carries a string index signature on its props type argument', () => { + // If this fails: move the `[key: string]: any` off the `forwardRef` type + // argument and onto the parameter annotation. On the type argument it makes + // `PropsWithoutRef` collapse the props to the bare index signature, which + // erases every declared property — and a required prop in the annotation + // then becomes a TS2345 on the render function, so the two halves have to + // move together. Do not allowlist. + const offenders = ALL_SITES.filter(s => s.indexSignatureOnTypeArg).map(rel); + expect(offenders).toEqual([]); + }); +}); diff --git a/packages/components/src/renderers/action/action-bar.tsx b/packages/components/src/renderers/action/action-bar.tsx index 15397ec32f..5dc0bb8584 100644 --- a/packages/components/src/renderers/action/action-bar.tsx +++ b/packages/components/src/renderers/action/action-bar.tsx @@ -9,12 +9,12 @@ /** * action:bar — Location-aware action toolbar. * - * Renders a set of ActionSchema items filtered by a given location. + * Renders a set of UIActionSchema items filtered by a given location. * Each action is rendered using its `component` type (action:button, action:icon, * action:menu, action:group) via the ComponentRegistry. Actions beyond the * `maxVisible` threshold are grouped into an overflow "More" dropdown. * - * This is the "bridge" component that connects ActionSchema metadata to the UI, + * This is the "bridge" component that connects UIActionSchema metadata to the UI, * enabling server-driven action rendering at every location the spec declares: * list_toolbar, list_item, record_header, record_more, record_related and * record_section. (`global_nav` used to close that list; it was retired from @@ -38,7 +38,7 @@ import React, { forwardRef, useMemo } from 'react'; import { ComponentRegistry } from '@object-ui/core'; -import type { ActionSchema, UIActionSchema, ActionLocation, ActionComponent } from '@object-ui/types'; +import type { UIActionSchema, ActionLocation, ActionComponent } from '@object-ui/types'; import { ACTION_LOCATIONS, actionRendersAt } from '@object-ui/types'; import { useCondition, toPredicateInput, useCapabilityGate } from '@object-ui/react'; import { useObjectTranslation } from '@object-ui/i18n'; @@ -57,7 +57,7 @@ function useActionsLabel(): string { export interface ActionBarSchema { type: 'action:bar'; /** Business actions to render — subject to inline/overflow split via {@link maxVisible} */ - actions?: ActionSchema[]; + actions?: UIActionSchema[]; /** * System/chrome actions (Duplicate, Export, View History, Delete, etc.) that * are *always* placed in the overflow menu — never inline — regardless of @@ -68,7 +68,7 @@ export interface ActionBarSchema { * The first system action is automatically separated from business-overflow * entries by a menu separator. */ - systemActions?: ActionSchema[]; + systemActions?: UIActionSchema[]; /** Filter actions by this location */ location?: ActionLocation; /** Maximum visible inline actions before overflow into "More" menu (default: 3) */ @@ -90,8 +90,29 @@ export interface ActionBarSchema { [key: string]: any; } -const ActionBarRenderer = forwardRef( - ({ schema, className, ...props }, ref) => { +// The index signature lives on the PARAMETER annotation and NOT on the +// `forwardRef` type argument. That asymmetry is load-bearing (objectui#4422) — +// see `__tests__/forwardref-props-annotation.guard.test.ts`, which pins it: +// +// * `forwardRef` routes its type argument through `PropsWithoutRef`, which is +// `'ref' extends keyof P ? Omit : P`. A string index signature +// puts `string` into `keyof P`, so the `Omit` branch ALWAYS runs, and +// `Omit` over an index-signature type keeps only the index signature — +// every declared property is erased. With the signature on the type +// argument, `schema` arrived as `any`. +// * Keeping it on the parameter annotation preserves the pass-through spread +// (`...props` still collects arbitrary keys for the DOM/Shadcn hand-off), +// so this is NOT the "drop the index signature" direction — no component's +// real prop surface had to be enumerated. +// +// The annotation cannot simply repeat the type argument: once `Omit` has erased +// `schema`, a required `schema` in the annotation is a TS2345 on the render +// function itself. Removing the signature from the type argument is what makes +// the direct annotation legal, and it is consumer-neutral — this const is not +// exported and never appears in JSX, and `Registry.register` takes +// `ComponentRenderer = T`. +const ActionBarRenderer = forwardRef( + ({ schema, className, ...props }: { schema: ActionBarSchema; className?: string; [key: string]: any }, ref) => { const actionsAriaLabel = useActionsLabel(); const { 'data-obj-id': dataObjId, @@ -129,26 +150,13 @@ const ActionBarRenderer = forwardRef { - // Annotated, not inferred, and `UIActionSchema` rather than the legacy - // `ActionSchema` this file imports for its declarations. Two facts, both - // measured in objectui#4353: - // - // 1. The declaration does not survive into `schema`. `forwardRef` routes - // props through `PropsWithoutRef`, whose `Omit` collapses a props type - // carrying `[key: string]: any` down to the bare index signature — so - // `schema` arrives as `any` and every callback below it inferred - // `any` too. One annotation at the point the list ENTERS types the - // whole `filter`/`some`/`map` chain by inference. - // 2. `UIActionSchema` is what actually flows in. The legacy - // `ActionSchema` (`crud.ts`, `@deprecated`) has no `locations`, so the - // shared `actionRendersAt` predicate rejects it outright, and its - // `variant` union has no `'primary'` — the value the objectui#2339 - // tie-break below compares against. This file's own doc example is a - // `UIActionSchema` (`type: 'script'`; legacy requires `type: 'action'`). - // - // The exported `ActionBarSchema.actions` key still DECLARES the legacy - // type — that mismatch predates this change, is filed separately, and is - // deliberately not migrated here (it reaches ~46 sites across 12 files). + // `UIActionSchema` all the way through, declaration included + // (objectui#4418). It used to be only the local: the exported + // `ActionBarSchema.actions` key declared the legacy `ActionSchema` while + // this implementation was written against the modern one, and #4353's + // annotation named that contradiction here rather than resolving it. + // The declaration has now moved, so the local is a plain restatement of + // `schema.actions`' own type and the chain below still infers from it. const actions: UIActionSchema[] = schema.actions || []; // [ADR-0066 D4 / framework#3923] Capability gate — this bar filters its // own set instead of going through `ActionEngine.getActionsForLocation`, @@ -203,7 +211,8 @@ const ActionBarRenderer = forwardRef { - // Same annotation, same two reasons as `filteredActions` above. + // Same type as `filteredActions` above, and now for the same plain + // reason — `systemActions` declares it too. const actions: UIActionSchema[] = schema.systemActions || []; const seen = new Set(); // Chrome or not, a declared capability gates it (ADR-0066 D4) — a host diff --git a/packages/components/src/renderers/action/action-button.tsx b/packages/components/src/renderers/action/action-button.tsx index d1adc18136..8c5d8bb326 100644 --- a/packages/components/src/renderers/action/action-button.tsx +++ b/packages/components/src/renderers/action/action-button.tsx @@ -7,7 +7,7 @@ */ /** - * action:button — Smart action button driven by ActionSchema. + * action:button — Smart action button driven by UIActionSchema. * * Renders a Shadcn Button wired to the ActionRunner. Supports: * - All 5 spec action types (script, url, modal, flow, api) @@ -20,7 +20,7 @@ import React, { forwardRef, useCallback, useState } from 'react'; import { ComponentRegistry } from '@object-ui/core'; import type { ActionDef } from '@object-ui/core'; -import type { ActionSchema } from '@object-ui/types'; +import type { UIActionSchema } from '@object-ui/types'; import { useAction } from '@object-ui/react'; import { useCondition, toPredicateInput, usePredicateRecordContext } from '@object-ui/react'; import { Button } from '../../ui'; @@ -30,16 +30,39 @@ import { resolveIcon } from './resolve-icon'; import { hasDeclaredVisibilityGate } from './visibility-gate'; import { hasAutoTrigger, useAutoTriggerOnce } from './auto-trigger'; +/** + * The declared props. `schema` is `UIActionSchema` (objectui#4418): every key + * this renderer forwards below — `target`, `endpoint`, `bodyExtra`, + * `bodyShape`, `locations`, `enabled`, `size` — is declared by the modern type + * and by no other, and the legacy `crud.ts` `ActionSchema` it used to name is + * `@deprecated` and requires `type: 'action'` (this renderer serves + * `'script' | 'url' | 'modal' | 'flow' | 'api'`). `actionType` stays on the + * intersection: it is the legacy-shaped override this renderer reads FIRST + * (`schema.actionType || schema.type`), and it is not a `UIActionSchema` key. + */ export interface ActionButtonProps { - schema: ActionSchema & { type: string; className?: string; actionType?: string }; + schema: UIActionSchema & { type: string; className?: string; actionType?: string }; className?: string; /** Override context for this specific action */ context?: Record; [key: string]: any; } -const ActionButtonRenderer = forwardRef( - ({ schema, className, context: localContext, ...props }, ref) => { +// `PropsWithoutRef` would collapse `ActionButtonProps` to its bare index +// signature, so the type argument carries the declared props WITHOUT it (each +// derived off `ActionButtonProps`, so the two cannot drift) and the index +// signature stays on the parameter annotation — mechanism note on `action:bar` +// (objectui#4422), pinned by +// `__tests__/forwardref-props-annotation.guard.test.ts`. +const ActionButtonRenderer = forwardRef< + HTMLButtonElement, + { + schema: ActionButtonProps['schema']; + className?: ActionButtonProps['className']; + context?: ActionButtonProps['context']; + } +>( + ({ schema, className, context: localContext, ...props }: ActionButtonProps, ref) => { const { 'data-obj-id': dataObjId, 'data-obj-type': dataObjType, diff --git a/packages/components/src/renderers/action/action-group.tsx b/packages/components/src/renderers/action/action-group.tsx index f4aad374ef..241e4a1b6d 100644 --- a/packages/components/src/renderers/action/action-group.tsx +++ b/packages/components/src/renderers/action/action-group.tsx @@ -18,7 +18,7 @@ import React, { forwardRef, useCallback, useState } from 'react'; import { ComponentRegistry } from '@object-ui/core'; -import type { ActionSchema, UIActionSchema, ActionGroup, ActionLocation } from '@object-ui/types'; +import type { UIActionSchema, ActionGroup, ActionLocation } from '@object-ui/types'; import { actionRendersAt } from '@object-ui/types'; import { useAction } from '@object-ui/react'; import { useCondition, toPredicateInput, usePredicateRecordContext } from '@object-ui/react'; @@ -44,7 +44,7 @@ export interface ActionGroupSchema { /** Group icon */ icon?: string; /** Actions in this group */ - actions?: ActionSchema[]; + actions?: UIActionSchema[]; /** Display mode: inline button row or dropdown */ display?: 'dropdown' | 'inline'; /** Filter actions by location */ @@ -207,8 +207,11 @@ export const DropdownActionItem: React.FC<{ DropdownActionItem.displayName = 'DropdownActionItem'; -const ActionGroupRenderer = forwardRef( - ({ schema, className, ...props }, ref) => { +// Index signature on the parameter annotation, not on the `forwardRef` type +// argument — see the mechanism note on `action:bar` (objectui#4422), pinned by +// `__tests__/forwardref-props-annotation.guard.test.ts`. +const ActionGroupRenderer = forwardRef( + ({ schema, className, ...props }: { schema: ActionGroupSchema; className?: string; [key: string]: any }, ref) => { const { 'data-obj-id': dataObjId, 'data-obj-type': dataObjType, @@ -232,14 +235,11 @@ const ActionGroupRenderer = forwardRef actionRendersAt(a, schema.location)); diff --git a/packages/components/src/renderers/action/action-icon.tsx b/packages/components/src/renderers/action/action-icon.tsx index 48c9acf184..020c5f2c33 100644 --- a/packages/components/src/renderers/action/action-icon.tsx +++ b/packages/components/src/renderers/action/action-icon.tsx @@ -15,7 +15,7 @@ import React, { forwardRef, useCallback, useState } from 'react'; import { ComponentRegistry } from '@object-ui/core'; import type { ActionDef } from '@object-ui/core'; -import type { ActionSchema } from '@object-ui/types'; +import type { UIActionSchema } from '@object-ui/types'; import { useAction } from '@object-ui/react'; import { useCondition, toPredicateInput, usePredicateRecordContext } from '@object-ui/react'; import { Button } from '../../ui'; @@ -25,15 +25,33 @@ import { Loader2 } from 'lucide-react'; import { resolveIcon } from './resolve-icon'; import { hasDeclaredVisibilityGate } from './visibility-gate'; +/** + * The declared props. `schema` is `UIActionSchema` (objectui#4418) for the same + * reason as `action:button`'s: `target`, `endpoint`, `bodyExtra`, `bodyShape`, + * `locations`, `enabled` and `size` are all modern-only keys this renderer + * forwards, and the legacy `crud.ts` `ActionSchema` is `@deprecated` and pins + * `type: 'action'` where this renderer's own registry `inputs` declare + * `'script' | 'url' | 'modal' | 'flow' | 'api'`. + */ export interface ActionIconProps { - schema: ActionSchema & { type: string; className?: string }; + schema: UIActionSchema & { type: string; className?: string }; className?: string; context?: Record; [key: string]: any; } -const ActionIconRenderer = forwardRef( - ({ schema, className, context: localContext, ...props }, ref) => { +// Index signature on the parameter annotation, not on the `forwardRef` type +// argument — mechanism note on `action:bar` (objectui#4422), pinned by +// `__tests__/forwardref-props-annotation.guard.test.ts`. +const ActionIconRenderer = forwardRef< + HTMLButtonElement, + { + schema: ActionIconProps['schema']; + className?: ActionIconProps['className']; + context?: ActionIconProps['context']; + } +>( + ({ schema, className, context: localContext, ...props }: ActionIconProps, ref) => { const { 'data-obj-id': dataObjId, 'data-obj-type': dataObjType, diff --git a/packages/components/src/renderers/action/action-menu.tsx b/packages/components/src/renderers/action/action-menu.tsx index 6469a7b23c..1944c77bc6 100644 --- a/packages/components/src/renderers/action/action-menu.tsx +++ b/packages/components/src/renderers/action/action-menu.tsx @@ -9,13 +9,13 @@ /** * action:menu — Dropdown menu for overflow actions. * - * Renders a Shadcn DropdownMenu populated from ActionSchema[]. + * Renders a Shadcn DropdownMenu populated from UIActionSchema[]. * Each menu item triggers the corresponding action via ActionRunner. */ import React, { forwardRef, useCallback, useMemo, useState } from 'react'; import { ComponentRegistry } from '@object-ui/core'; -import type { ActionSchema, UIActionSchema } from '@object-ui/types'; +import type { UIActionSchema } from '@object-ui/types'; import { useAction } from '@object-ui/react'; import { useCondition, toPredicateInput, usePredicateRecordContext } from '@object-ui/react'; import { useObjectTranslation } from '@object-ui/i18n'; @@ -49,7 +49,7 @@ export interface ActionMenuSchema { /** Menu trigger icon (defaults to more-horizontal) */ icon?: string; /** Actions to render as menu items */ - actions?: ActionSchema[]; + actions?: UIActionSchema[]; /** Trigger variant */ variant?: string; /** Trigger size */ @@ -172,8 +172,11 @@ const ActionAutoTrigger: React.FC<{ ActionAutoTrigger.displayName = 'ActionAutoTrigger'; -const ActionMenuRenderer = forwardRef( - ({ schema, className, ...props }, ref) => { +// Index signature on the parameter annotation, not on the `forwardRef` type +// argument — see the mechanism note on `action:bar` (objectui#4422), pinned by +// `__tests__/forwardref-props-annotation.guard.test.ts`. +const ActionMenuRenderer = forwardRef( + ({ schema, className, ...props }: { schema: ActionMenuSchema; className?: string; [key: string]: any }, ref) => { const { 'data-obj-id': dataObjId, 'data-obj-type': dataObjType, @@ -255,13 +258,10 @@ const ActionMenuRenderer = forwardRef( - ({ schema, className, ...props }, ref) => { +// Index signature on the parameter annotation, not on the `forwardRef` type +// argument — mechanism note on `action:bar` (objectui#4422), pinned by +// `__tests__/forwardref-props-annotation.guard.test.ts`. +const DivRenderer = forwardRef( + ({ schema, className, ...props }: { schema: DivSchema; className?: string; [key: string]: any }, ref) => { // Deprecation warning (once per module load — see warnDeprecatedOnce) warnDeprecatedOnce( 'div', diff --git a/packages/components/src/renderers/basic/html-elements.tsx b/packages/components/src/renderers/basic/html-elements.tsx index a50598983c..1f8c05713a 100644 --- a/packages/components/src/renderers/basic/html-elements.tsx +++ b/packages/components/src/renderers/basic/html-elements.tsx @@ -102,7 +102,13 @@ function toInternalPath(href: unknown): string | null { for (const tag of TAGS) { const isVoid = VOID_TAGS.has(tag); - const Component = forwardRef(({ schema, className, ...props }, ref) => { + // Index signature on the parameter annotation, not on the `forwardRef` type + // argument — mechanism note on `action:bar` (objectui#4422), pinned by + // `__tests__/forwardref-props-annotation.guard.test.ts`. `schema` is + // genuinely `any` here (one factory over every raw HTML tag), so nothing is + // recovered by the annotation; it is written the same way as its 11 siblings + // so the guard needs no per-file carve-out. + const Component = forwardRef(({ schema, className, ...props }: AnyProps, ref) => { const { 'data-obj-id': dataObjId, 'data-obj-type': dataObjType, diff --git a/packages/components/src/renderers/basic/icon.tsx b/packages/components/src/renderers/basic/icon.tsx index 739ff18e6e..845986a5c4 100644 --- a/packages/components/src/renderers/basic/icon.tsx +++ b/packages/components/src/renderers/basic/icon.tsx @@ -26,8 +26,11 @@ const iconNameMap: Record = { 'Home': 'House', // "Home" was renamed to "House" in lucide-react's icons object }; -const IconRenderer = forwardRef( - ({ schema, className, ...props }, ref) => { +// Index signature on the parameter annotation, not on the `forwardRef` type +// argument — mechanism note on `action:bar` (objectui#4422), pinned by +// `__tests__/forwardref-props-annotation.guard.test.ts`. +const IconRenderer = forwardRef( + ({ schema, className, ...props }: { schema: IconSchema; className?: string; [key: string]: any }, ref) => { // Extract designer-related props const { 'data-obj-id': dataObjId, diff --git a/packages/components/src/renderers/basic/separator.tsx b/packages/components/src/renderers/basic/separator.tsx index 02fd436b44..ba27c00dc7 100644 --- a/packages/components/src/renderers/basic/separator.tsx +++ b/packages/components/src/renderers/basic/separator.tsx @@ -11,8 +11,11 @@ import type { SeparatorSchema } from '@object-ui/types'; import { Separator } from '../../ui'; import { forwardRef } from 'react'; -const SeparatorRenderer = forwardRef( - ({ schema, className, ...props }, ref) => { +// Index signature on the parameter annotation, not on the `forwardRef` type +// argument — mechanism note on `action:bar` (objectui#4422), pinned by +// `__tests__/forwardref-props-annotation.guard.test.ts`. +const SeparatorRenderer = forwardRef( + ({ schema, className, ...props }: { schema: SeparatorSchema; className?: string; [key: string]: any }, ref) => { // Extract designer-related props const { 'data-obj-id': dataObjId, diff --git a/packages/components/src/renderers/basic/span.tsx b/packages/components/src/renderers/basic/span.tsx index 58f2862b99..3c52d9d7c8 100644 --- a/packages/components/src/renderers/basic/span.tsx +++ b/packages/components/src/renderers/basic/span.tsx @@ -11,8 +11,11 @@ import type { TextSpanSchema } from '@object-ui/types'; import { renderChildren } from '../../lib/utils'; import { forwardRef } from 'react'; -const SpanRenderer = forwardRef( - ({ schema, className, ...props }, ref) => { +// Index signature on the parameter annotation, not on the `forwardRef` type +// argument — mechanism note on `action:bar` (objectui#4422), pinned by +// `__tests__/forwardref-props-annotation.guard.test.ts`. +const SpanRenderer = forwardRef( + ({ schema, className, ...props }: { schema: TextSpanSchema; className?: string; [key: string]: any }, ref) => { // Deprecation warning if (process.env.NODE_ENV !== 'production') { console.warn( diff --git a/packages/components/src/renderers/form/button.tsx b/packages/components/src/renderers/form/button.tsx index ab5c23413c..2dd211837d 100644 --- a/packages/components/src/renderers/form/button.tsx +++ b/packages/components/src/renderers/form/button.tsx @@ -26,8 +26,11 @@ const iconNameMap: Record = { 'Home': 'House', }; -const ButtonRenderer = forwardRef( - ({ schema, ...props }, ref) => { +// Index signature on the parameter annotation, not on the `forwardRef` type +// argument — mechanism note on `action:bar` (objectui#4422), pinned by +// `__tests__/forwardref-props-annotation.guard.test.ts`. +const ButtonRenderer = forwardRef( + ({ schema, ...props }: { schema: ButtonSchema; [key: string]: any }, ref) => { // Extract designer-related props const { 'data-obj-id': dataObjId, diff --git a/packages/components/src/renderers/layout/card.tsx b/packages/components/src/renderers/layout/card.tsx index b6a54dccfa..bb323237f6 100644 --- a/packages/components/src/renderers/layout/card.tsx +++ b/packages/components/src/renderers/layout/card.tsx @@ -19,8 +19,11 @@ import { } from '../../ui'; import { forwardRef } from 'react'; -const CardRenderer = forwardRef( - ({ schema, className, ...props }, ref) => { +// Index signature on the parameter annotation, not on the `forwardRef` type +// argument — mechanism note on `action:bar` (objectui#4422), pinned by +// `__tests__/forwardref-props-annotation.guard.test.ts`. +const CardRenderer = forwardRef( + ({ schema, className, ...props }: { schema: CardSchema; className?: string; [key: string]: any }, ref) => { // Extract designer-related props const { 'data-obj-id': dataObjId, diff --git a/packages/components/src/renderers/layout/container.tsx b/packages/components/src/renderers/layout/container.tsx index 03c472ca37..340aa9fca5 100644 --- a/packages/components/src/renderers/layout/container.tsx +++ b/packages/components/src/renderers/layout/container.tsx @@ -12,8 +12,11 @@ import { renderChildren } from '../../lib/utils'; import { cn } from '../../lib/utils'; import { forwardRef } from 'react'; -const ContainerRenderer = forwardRef( - ({ schema, className, ...props }, ref) => { +// Index signature on the parameter annotation, not on the `forwardRef` type +// argument — mechanism note on `action:bar` (objectui#4422), pinned by +// `__tests__/forwardref-props-annotation.guard.test.ts`. +const ContainerRenderer = forwardRef( + ({ schema, className, ...props }: { schema: ContainerSchema; className?: string; [key: string]: any }, ref) => { const maxWidth = (schema.maxWidth || 'xl') as any; const padding = schema.padding || 4; const centered = schema.centered !== false; // Default to true diff --git a/packages/components/src/renderers/layout/semantic.tsx b/packages/components/src/renderers/layout/semantic.tsx index 82427a8c84..5bb9e9be1f 100644 --- a/packages/components/src/renderers/layout/semantic.tsx +++ b/packages/components/src/renderers/layout/semantic.tsx @@ -13,7 +13,13 @@ import { forwardRef } from 'react'; const tags = ['aside', 'main', 'header', 'nav', 'footer', 'section', 'article'] as const; tags.forEach(tag => { - const Component = forwardRef(({ schema, className, ...props }, ref) => { + // Index signature on the parameter annotation, not on the `forwardRef` type + // argument — mechanism note on `action:bar` (objectui#4422), pinned by + // `__tests__/forwardref-props-annotation.guard.test.ts`. This factory covers + // seven semantic tags with no schema type of their own, so `schema` stays + // `any`; the annotation is written like its siblings' so the guard needs no + // per-file carve-out. + const Component = forwardRef(({ schema, className, ...props }: { schema: any; className?: string; [key: string]: any }, ref) => { // Extract designer-related props const { 'data-obj-id': dataObjId, diff --git a/packages/components/src/renderers/layout/stack.tsx b/packages/components/src/renderers/layout/stack.tsx index d396ccad68..2408e14773 100644 --- a/packages/components/src/renderers/layout/stack.tsx +++ b/packages/components/src/renderers/layout/stack.tsx @@ -13,8 +13,11 @@ import { cn } from '../../lib/utils'; import { forwardRef } from 'react'; // Stack is essentially a Flex container that defaults to column direction -const StackRenderer = forwardRef( - ({ schema, className, ...props }, ref) => { +// Index signature on the parameter annotation, not on the `forwardRef` type +// argument — mechanism note on `action:bar` (objectui#4422), pinned by +// `__tests__/forwardref-props-annotation.guard.test.ts`. +const StackRenderer = forwardRef( + ({ schema, className, ...props }: { schema: StackSchema; className?: string; [key: string]: any }, ref) => { // Default to column for Stack const direction = schema.direction || 'col'; const justify = schema.justify || 'start';