Skip to content

Commit f486e9c

Browse files
fix(types,components)!: renderer props index signatures map to unknown, not any (#26293)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi911oA3FnMcWttjcfML6MnUg)_ Part of #25918. This PR is the triage in-scope item named in the claim: `ComponentRendererProps` and the inline renderer props copies under `ui/packages/components/src/renderers/**` move from an index signature to `any` to one to `unknown`. #25918 remains open for the other items: the `(schema as any)` reads in ListView / ObjectGrid / ObjectView (a separate slice), and the `no-explicit-any` lint gate on `ui/packages/types/src`, which triage holds out of scope until the maintainer names it. No ESLint config change here. Clause-②: yes (narrowing) Metadata-protocol: none ## What changed - **`@object-ui/types`**: `ComponentRendererProps` keeps its index signature (forwarded keys stay admitted, the SchemaRenderer forward path is untouched), but the signature maps to `unknown`, so a renderer has to narrow a forwarded key before it uses it. - **`@object-ui/components`**: the 77 renderer props annotations (74 files) that carried an index signature to `any` now carry one to `unknown`. The class of reads that broke is declared, not cast: - `RendererHostProps` (new, package-local, `src/lib/renderer-host-props.ts`) types the three host keys renderers read by name and hand to their root element: `data-obj-id`, `data-obj-type` (written by `SchemaRenderer` from the node's `id` / `type`) and `style` (forwarded from `BaseSchema.style`). It is intersected into the 49 annotations that read them. - Host-supplied keys a single renderer reads by name are declared on that renderer: `onPageChange` (`pagination`), `onChange` / `value` (`input-otp`), `loading` (`button`), `className` (`text`), and on the form's internal `RenderFieldProps` react-hook-form's `ref` (`RefCallBack`), `field` and `emptyHint`. - `filter-builder` needs no cast: main's #26306 declared `value?: FilterGroup` on the renderer schema, so the read type-checks as written (seat note, head `b9a43f1d`). - Changeset: `.changeset/25918-renderer-props-unknown-index.md` (`patch` on both packages, BREAKING type narrowing, FROM → TO migration for a renderer typed with `ComponentRendererProps`). - sdui: no records. Since #26367 the per-package records are retired; the merge of `main` dropped `scripts/sdui-manifest.sources/{types,components}.json`, and CI's Build & E2E UI regenerates `sdui.manifest.json` itself. ## Measured (origin/main 617f27e + this branch) - Step 0, the `base.ts` one-liner alone: 0 type errors in `@object-ui/components`, `@object-ui/types`, `@object-ui/react` and `@object-ui/core` (`type-check`, both programs each). `ComponentRendererProps` has no renderer consumer; its only readers are the reconciliation pin in `@object-ui/react` and the closed-face pin in `@object-ui/types`, which asserts the index signature exists and stays green. - Flipping the inline copies alone: 65 errors in 49 files on components' `tsc --noEmit`; about 45 of them were the host-key class above. - Public surface: none of the renderer props types is exported from `@object-ui/components`' entry (`src/index.ts`), so no downstream package's type-check reads them; `ComponentRendererProps` has no consumer outside the two pins. ## Tests and gates (head `b9a43f1d`; earlier readings at 67418d8 / e349c83 kept where still true) - `pnpm --filter @object-ui/components type-check`: exit 0 (`tsc --noEmit && tsc -p tsconfig.test.json`). `@object-ui/types`, `@object-ui/react`, `@object-ui/core` `type-check`: exit 0. - `pnpm --dir ui exec vitest run packages/components/ packages/types/ packages/react/src/__tests__/ComponentRendererProps.reconciliation.test.ts --maxWorkers=2`: Test Files 826 passed, 1 skipped (827); Tests 14866 passed, 24 skipped. - At head `b9a43f1d` (merge of `main` `e890bedc`), `node scripts/check-adr-0087-registration.mjs --base origin/main` exits 0: "verified: ui/packages/types/src/base.ts#ComponentRendererProps ([key: string]: any -> [key: string]: unknown)". The gate now reads ui packages (#26319) and admits an `any` → `unknown` index-signature change (D8, ADR amendment #26455, gate #26496). The changeset keeps `type-surface-only`. dispatch-gates: 28 derived, 28/28 at `b9a43f1d`. - Dogfood full battery (owed for the `(narrowing)` arm), on head `b9a43f1d`, run as CI runs it (build `@objectstack/dogfood^...`, then `pnpm --filter @objectstack/dogfood test`): command exit 0. Test Files 300 passed, 3 skipped (303); Tests 2494 passed, 18 skipped (2512). ## Acceptance notes - `ActionGroupSchema` and `ActionMenuSchema` (in `action-group.tsx` / `action-menu.tsx`) are exported schema interfaces, not renderer props, and still carry an index signature to `any`; they are outside this claim and left byte-identical. - `html-elements.tsx`'s shared `AnyProps` now has an `unknown` index signature but still types `schema: any`; that member is not an index signature and is outside this claim. - `filter-builder`: the `FilterGroup` / `FilterBuilderRowGroup` gap is closed on `main` by #26306, so this PR no longer carries a cast there. - `ui/AGENTS.md` #0.1 says renderer props keep their signature (tolerance); the signature is kept as an index signature, only its value type changes, which is the triage item's own wording ("forwarded keys stay admitted"). --- _Generated by [Claude Code](https://claude.ai/code/session_01GJjkZ6pmdwWoDYH8fE8FAn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 416d9dd commit f486e9c

77 files changed

Lines changed: 199 additions & 82 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@object-ui/types': patch
3+
'@object-ui/components': patch
4+
---
5+
6+
fix(types,components)!: a renderer's props index signature is `unknown`, not `any`, so a forwarded key must be narrowed before it is used
7+
8+
Clause-②: yes (narrowing)
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (type-surface-only ui/packages/types/src/base.ts#ComponentRendererProps) A TypeScript annotation narrowing on a published renderer-props type: no metadata key, accepted value, export or error code is removed or renamed, nothing at runtime changes, and `objectstack migrate meta` has nothing to rewrite. The affected party is a TypeScript consumer, reached by its own compiler. -->
12+
13+
**BREAKING** type-surface narrowing, compile-time only. Nothing a renderer receives or renders at runtime changes.
14+
15+
- **`@object-ui/types`.** `ComponentRendererProps` keeps its index signature, so every key the host forwards is still admitted, but the signature now maps to `unknown` instead of `any`.
16+
- **`@object-ui/components`.** The built-in renderers' own inline props annotations move the same way. The three host keys renderers read by name and pass to their root element (`data-obj-id`, `data-obj-type` and `style`, written or forwarded by `SchemaRenderer`) are declared once in a package-local type instead. The host-supplied callbacks and values a renderer reads by name (`onPageChange` on `pagination`, `onChange` / `value` on `input-otp`, `loading` on `button`, `className` on `text`, react-hook-form's `ref` on the form's built-in controls) are declared on that renderer's props.
17+
18+
**Migration.** A renderer typed with `ComponentRendererProps` that reads a forwarded key:
19+
20+
```ts
21+
// FROM — compiled, because the key was `any`
22+
const Pager = ({ schema, ...props }: ComponentRendererProps<PagerSchema>) =>
23+
<Nav onSelect={(n) => props.onPageChange(n)} />;
24+
25+
// TO — declare the key you read on your own props, or narrow it before use
26+
type PagerProps = ComponentRendererProps<PagerSchema> & { onPageChange?: (page: number) => void };
27+
const Pager = ({ schema, onPageChange }: PagerProps) =>
28+
<Nav onSelect={(n) => onPageChange?.(n)} />;
29+
```
30+
31+
What to do: nothing if your renderers never read a forwarded key off `ComponentRendererProps` without declaring it. Otherwise declare the key on your renderer's props (as above) or narrow the `unknown` value (`typeof x === 'function'`, `typeof x === 'string'`) before using it.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import type { CSSProperties } from 'react';
10+
11+
/**
12+
* The host keys a renderer in this package reads BY NAME off its props and
13+
* hands to its root element (#25918).
14+
*
15+
* A renderer's props carry an index signature to `unknown`, not `any`: a key
16+
* the host forwards is still admitted, but the renderer has to narrow it
17+
* before it can use it. These three are the exception that is declared rather
18+
* than narrowed at every site, because they are not authoring keys a renderer
19+
* interprets — `SchemaRenderer` (`@object-ui/react`) writes `data-obj-id` from
20+
* the node's `id` and `data-obj-type` from its `type`, and forwards the node's
21+
* `style` (`BaseSchema.style`) — and every renderer that reads them passes
22+
* them straight to the DOM.
23+
*
24+
* Intersect it into a renderer's props annotation; ⛔ never widen the index
25+
* signature back to `any` to make a by-name read compile.
26+
*/
27+
export interface RendererHostProps {
28+
'data-obj-id'?: string;
29+
'data-obj-type'?: string;
30+
style?: CSSProperties;
31+
}

‎ui/packages/components/src/renderers/action/action-bar.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import { cn } from '../../lib/utils';
5757
import { useIsMobile } from '../../hooks/use-mobile';
5858
import { ButtonGroup } from '../../custom/button-group';
5959
import { withEvaluatedProperties } from './static-params';
60+
import type { RendererHostProps } from '../../lib/renderer-host-props';
6061

6162
function useActionsLabel(): string {
6263
// useObjectTranslation is provider-safe (never throws); no try/catch, which
@@ -94,7 +95,7 @@ function useActionsLabel(): string {
9495
// exported and never appears in JSX, and `Registry.register` takes
9596
// `ComponentRenderer<T = any> = T`.
9697
const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema; className?: string }>(
97-
({ schema, className, ...props }: { schema: ActionBarSchema; className?: string; [key: string]: any }, ref) => {
98+
({ schema, className, ...props }: { schema: ActionBarSchema; className?: string; [key: string]: unknown } & RendererHostProps, ref) => {
9899
const actionsAriaLabel = useActionsLabel();
99100
const {
100101
'data-obj-id': dataObjId,

‎ui/packages/components/src/renderers/action/action-button.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { hasDeclaredVisibilityGate } from './visibility-gate';
3232
import { useAutoTriggerOnce } from './auto-trigger';
3333
import { readStaticParamValues } from './static-params';
3434
import { DisabledReasonTrigger, describedByWithReason, useDisabledReason } from './disabled-reason';
35+
import type { RendererHostProps } from '../../lib/renderer-host-props';
3536

3637
/** A record value: a plain object, never `null` or an array. */
3738
function asRecord(value: unknown): Record<string, unknown> | undefined {
@@ -97,7 +98,7 @@ function withUndoBaseline(
9798
// `ActionButtonProps` as the block's AUTHORED property bag (`actionType`, `label`, `variant`, …).
9899
// This is the React envelope that carries a node (`schema`, `context`, `disabled`, an open
99100
// tail) — a different layer, named as objectui#7265 named `RecordAlertRendererProps`.
100-
export interface ActionButtonRendererProps {
101+
export interface ActionButtonRendererProps extends RendererHostProps {
101102
schema: UIActionSchema & { type: string; className?: string; actionType?: string };
102103
className?: string;
103104
/** Override context for this specific action */
@@ -110,7 +111,7 @@ export interface ActionButtonRendererProps {
110111
* therefore never reaches the DOM spread — see the destructure.
111112
*/
112113
disabled?: boolean;
113-
[key: string]: any;
114+
[key: string]: unknown;
114115
}
115116

116117
// `PropsWithoutRef` would collapse `ActionButtonRendererProps` to its bare index

‎ui/packages/components/src/renderers/action/action-group.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
menuItemReasonAria,
4343
useDisabledReason,
4444
} from './disabled-reason';
45+
import type { RendererHostProps } from '../../lib/renderer-host-props';
4546

4647
// No group-level `name` (objectui#11168): nothing renders, forwards or keys on
4748
// it, and `@objectstack/spec` refuses it on this block, so the registration
@@ -275,7 +276,7 @@ DropdownActionItem.displayName = 'DropdownActionItem';
275276
// host-EVALUATED enablement verdict, declared rather than left to the index
276277
// signature, as `ActionButtonRendererProps` declares it (objectui#9131).
277278
const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSchema; className?: string }>(
278-
({ schema, className, ...props }: { schema: ActionGroupSchema; className?: string; disabled?: boolean; [key: string]: any }, ref) => {
279+
({ schema, className, ...props }: { schema: ActionGroupSchema; className?: string; disabled?: boolean; [key: string]: unknown } & RendererHostProps, ref) => {
279280
const {
280281
'data-obj-id': dataObjId,
281282
'data-obj-type': dataObjType,

‎ui/packages/components/src/renderers/action/action-icon.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { hasDeclaredVisibilityGate } from './visibility-gate';
2828
import { useAutoTriggerOnce } from './auto-trigger';
2929
import { readStaticParamValues } from './static-params';
3030
import { DisabledReasonTrigger, describedByWithReason, useDisabledReason } from './disabled-reason';
31+
import type { RendererHostProps } from '../../lib/renderer-host-props';
3132

3233
/**
3334
* The declared props. `schema` is `UIActionSchema` (objectui#4418) for the same
@@ -44,7 +45,7 @@ import { DisabledReasonTrigger, describedByWithReason, useDisabledReason } from
4445
// `ActionIconProps` as the block's AUTHORED property bag (`actionType`, `label`, `variant`, …).
4546
// This is the React envelope that carries a node (`schema`, `context`, `disabled`, an open
4647
// tail) — a different layer, named as objectui#7265 named `RecordAlertRendererProps`.
47-
export interface ActionIconRendererProps {
48+
export interface ActionIconRendererProps extends RendererHostProps {
4849
schema: UIActionSchema & { type: string; className?: string; actionType?: string };
4950
className?: string;
5051
context?: Record<string, any>;
@@ -54,7 +55,7 @@ export interface ActionIconRendererProps {
5455
* signature, consumed by name below, never re-spread onto the DOM.
5556
*/
5657
disabled?: boolean;
57-
[key: string]: any;
58+
[key: string]: unknown;
5859
}
5960

6061
// Index signature on the parameter annotation, not on the `forwardRef` type

‎ui/packages/components/src/renderers/action/action-menu.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { hasDeclaredVisibilityGate } from './visibility-gate';
3535
import { useAutoTriggerOnce } from './auto-trigger';
3636
import { readActionEntryParamValues } from './static-params';
3737
import { DisabledReasonMenuLabel, menuItemReasonAria, useDisabledReason } from './disabled-reason';
38+
import type { RendererHostProps } from '../../lib/renderer-host-props';
3839

3940
function useMoreActionsLabel(): string {
4041
// useObjectTranslation is provider-safe (never throws); no try/catch, which
@@ -207,7 +208,7 @@ ActionAutoTrigger.displayName = 'ActionAutoTrigger';
207208
// host-EVALUATED enablement verdict, declared rather than left to the index
208209
// signature, as `ActionButtonRendererProps` declares it (objectui#9131).
209210
const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSchema; className?: string }>(
210-
({ schema, className, ...props }: { schema: ActionMenuSchema; className?: string; disabled?: boolean; [key: string]: any }, ref) => {
211+
({ schema, className, ...props }: { schema: ActionMenuSchema; className?: string; disabled?: boolean; [key: string]: unknown } & RendererHostProps, ref) => {
211212
const {
212213
'data-obj-id': dataObjId,
213214
'data-obj-type': dataObjType,

‎ui/packages/components/src/renderers/basic/button-group.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ import { ComponentRegistry } from '@object-ui/core';
1010
import type { ButtonGroupSchema } from '@object-ui/types';
1111
import { Button } from '../../ui';
1212
import { cn } from '../../lib/utils';
13+
import type { RendererHostProps } from '../../lib/renderer-host-props';
1314

1415
ComponentRegistry.register('button-group',
15-
({ schema, ...props }: { schema: ButtonGroupSchema; [key: string]: any }) => {
16+
({ schema, ...props }: { schema: ButtonGroupSchema; [key: string]: unknown } & RendererHostProps) => {
1617
const {
1718
'data-obj-id': dataObjId,
1819
'data-obj-type': dataObjType,

‎ui/packages/components/src/renderers/basic/div.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ComponentRegistry } from '@object-ui/core';
1010
import type { DivSchema } from '@object-ui/types';
1111
import { renderChildren } from '../../lib/utils';
1212
import { forwardRef } from 'react';
13+
import type { RendererHostProps } from '../../lib/renderer-host-props';
1314

1415
/**
1516
* Deprecated types already reported in this module instance — the notice is a
@@ -128,7 +129,7 @@ const DIV_DEPRECATION_NOTICE =
128129
// argument — mechanism note on `action:bar` (objectui#4422), pinned by
129130
// `__tests__/forwardref-props-annotation.guard.test.ts`.
130131
const DivRenderer = forwardRef<HTMLDivElement, { schema: DivSchema; className?: string }>(
131-
({ schema, className, ...props }: { schema: DivSchema; className?: string; [key: string]: any }, ref) => {
132+
({ schema, className, ...props }: { schema: DivSchema; className?: string; [key: string]: unknown } & RendererHostProps, ref) => {
132133
// Deprecation notice, once per module load (objectui#3965, see
133134
// warnDeprecatedOnce). There is no provenance exemption any more: the
134135
// declaration below names both authoring surfaces (objectui#10757), so a

‎ui/packages/components/src/renderers/basic/html-elements.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { renderChildren } from '../../lib/utils';
3131
import { createElement, forwardRef, useContext } from 'react';
3232
import type { MouseEventHandler } from 'react';
3333

34-
type AnyProps = { schema: any; className?: string; [key: string]: any };
34+
type AnyProps = { schema: any; className?: string; [key: string]: unknown };
3535

3636
// Tags that take no children.
3737
const VOID_TAGS = new Set(['img', 'hr', 'br']);

0 commit comments

Comments
 (0)