Skip to content

fix(react): SchemaRenderer states its real contract — typed schema, deliberate forwarding, no accidental erasure (#4548) - #4578

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4548-schemarenderer-open-contract
Aug 13, 2026
Merged

fix(react): SchemaRenderer states its real contract — typed schema, deliberate forwarding, no accidental erasure (#4548)#4578
yinlianghui merged 1 commit into
mainfrom
claude/issue-4548-schemarenderer-open-contract

Conversation

@yinlianghui

@yinlianghui yinlianghui commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Fixes #4548

SchemaRenderer is the renderer loop — every registered SDUI component is rendered through it, and it is the thing that hands widgets their props. Its own props were erased.

Measured first, on the pre-fix source

Probed through packages/react/tsconfig.test.json before anything was edited (the probe was throwaway; the permanent pin is SchemaRenderer.propsResolution.test.ts):

__probe4548__.test.tsx(20,7): Type 'string' is not assignable to type 'never'.        // keyof ComponentProps< typeof SchemaRenderer >
__probe4548__.test.tsx(22,7): Type 'any' is not assignable to type 'never'.           // ComponentProps< typeof SchemaRenderer >['schema']
__probe4548__.test.tsx(24,7): Type 'SchemaNode' is not assignable to type 'never'.    // the DECLARED type argument's ['schema']
__probe4548__.test.tsx(26,7): Type '"ERASED"' is not assignable to type 'never'.      // string extends keyof CallSiteProps

Declaration right, nobody held to it. The other half is the same defect seen from the call site — these three compiled silently, which is the measurement:

< SchemaRenderer / >                                  // no schema at all
< SchemaRenderer schema={12345} / >
< SchemaRenderer schema={{}} bogusPropNoOneReads={1} / >

forwardRef< T, P > routes P through PropsWithoutRef, which is 'ref' extends keyof Props ? Omit< Props, 'ref' > : Props. A string index signature puts string into keyof Props, so the Omit branch always runs, and Omit over a type carrying a string index signature keeps only the index signature. Spelled Record< string, any > rather than [key: string]: any, which is why every previous sweep's grep and both shipped guards' detector reported this site as clean.

The fix keeps the forwarding surface, deliberately

This component forwards every prop it does not read to the component the schema names, resolved at runtime from a plugin-extensible registry. packages/react/README.md documents exactly that — < SchemaRenderer schema={formSchema} onSubmit={handleSubmit} / > — and @object-ui/components' form renderer destructures that onSubmit as a React prop and invokes it. Closing the surface would state a false contract and force every leaf plugin's props into this package.

So the two halves are separated:

  • the forwardRef type argument is the honest SchemaRendererProps, with no index signature — nothing for PropsWithoutRef to collapse; and
  • the open surface is stated once in an explicit export annotation, which nothing routes through Omit, so it widens without erasing.

Visible in the shipped .d.ts (built both ways from a clean dist, spaces after each < for the sanitizer):

- export declare const SchemaRenderer: React.ForwardRefExoticComponent< Omit< {
-     schema: SchemaNode;
- } & Record< string, any >, "ref" > & React.RefAttributes< any > >;
+ export declare const SchemaRenderer: ForwardRefExoticComponent< SchemaRendererProps & ForwardedProps & RefAttributes< unknown > >;

That Omit< …, "ref" > is the erasure itself, materialised in the published artifact.

The declared input, and one declared behaviour change

SchemaRendererProps.schema is BaseSchema | string | null | undefined — what this component actually handles. It previously declared @object-ui/core's SchemaNode interface, which requires type: string and so contradicted the component's own early returns for strings and nullish, while every caller held @object-ui/types' wider union. The erasure hid that mismatch completely.

A non-object, non-string primitive now renders as its own text. It previously fell through to { ...schema }, which spreads a primitive to an empty object, lost the type the renderer then looked up, and surfaced the red "Unknown component type: undefined" box — an accident of the spread, not a decision. The declared type still excludes number / boolean so no author is invited to pass them; the runtime handling is defence-in-depth. Strings, null, undefined, 0 and false render exactly as before, and an object naming an unregistered type still gets the error box — all pinned in SchemaRenderer.primitiveSchema.test.tsx.

Canary: the full repo-wide type-check

Baseline on the unmodified worktree was 80/80. The fix is 80/80 again, with these latent defects — every one hidden by the erasure — fixed at their call sites:

  1. DashboardRenderer cast its widget schema as Record< string, any >, dropping the type that every branch of getComponentSchema actually sets.
  2. DashboardGridLayout's equivalent inferred a union admitting a shape with no type (the passthrough fallback spreads a DashboardWidgetSchema, whose type is optional), narrowed once at the definition site.
  3. ReportViewer handed a section's content array to the renderer whole — an array has no type, so a multi-node section rendered the unknown-component box instead of its content. Arrays are mapped, not widened into the declared input.

Forwarding sites that hold @object-ui/types' wider SchemaNode go through toRenderableSchema, a total function rather than a cast: it maps the two primitive members onto their text form, which is precisely what the renderer's own defensive branch does, so it changes no behaviour.

No consumer relies on arbitrary passthrough in a way this breaks: the surface is kept, and the canary reports zero excess-prop errors.

Guard: repo-wide, with the Record-aware detector

scripts/__tests__/forwardref-props-erasure.guard.test.ts replaces the two per-package siblings' blocked direction 3. Measured across every packages/* src: 219 forwardRef sites own their props type, 1 carried the erasure, now 0.

Discrimination proof, run against the pre-fix shape (fix removed via git checkout, restored and sha256-verified byte-identical):

FAIL  scripts/__tests__/forwardref-props-erasure.guard.test.ts > no forwardRef carries a string index signature on its props type argument
  + [ "packages/react/src/SchemaRenderer.tsx:204 [Record< string, … >]" ]

Test Files  1 failed (1)
     Tests  1 failed | 2 passed (3)

Against this branch: 3 passed (3). The detector resolves Record< string, … > by name plus its first type argument, and any mapped type keyed by string, in addition to literal index signatures — the gap this card documents.

It judges the type argument only, never an export annotation. That distinction is the claim, not an exemption: an index signature on the type argument is an accidental eraser (it is fed to PropsWithoutRef), whereas one in an export annotation is a stated contract applied to the already-built component, where nothing collapses. The fixed SchemaRenderer therefore passes on the merits — there is no allowlist.

#4551's second assertion (every destructuring forwardRef annotates its parameter) stays per-package and is deliberately not lifted: repo-wide it names 200 sites, overwhelmingly the shadcn/ui primitives in packages/components/src/ui/* plus plugin-timeline. None is erased — their type arguments carry no index signature, so PropsWithoutRef takes its identity branch. Sweeping it would turn 200 non-defects red and bury the one assertion that names a defect. Recorded in the guard header.

Grading

@object-ui/react minor, on #4528's reasoning quoted in the changeset: the type argument has always declared schema; the index signature erased it from the resolved type, and restoring what the declaration documents is a fix to the published contract, not a break. Consumers patch, per their own diffs. Never major.

must-not-change

Bundle byte-identity does not apply here and is not claimed: the SchemaNode source and the new primitive guard change the emitted JS. It is replaced by behaviour pins — strings / nullish / 0 / false render identically, unknown-type objects keep the error box, and the touched packages' runtime suites are green and untouched.

Verification

All re-run after rebasing onto current main (eb7f586b6), since packages/types and packages/core both moved under this branch:

  • turbo run type-check (full, as CI runs it): 80 successful, 80 total — baseline-matching
  • all six touched packages build clean; both tsc passes per package via type-check
  • @object-ui/react: 41 files, 566 tests green (all of them), plus the repo-wide guard
  • touched consumers (components, plugin-dashboard, plugin-detail, plugin-report, plugin-view): 273 files, 2475 tests green
  • eslint on the modified files: 164 warnings vs 165 baseline, 0 errors — net negative; the new source files add 0
  • check-control-bytes, check-changeset-presence / -no-major / -fixed, check-phantom-dependencies: green
  • control-byte self-scan (grep -naP) over every created and edited file, including untracked: clean

Left as draft for PM step-7 review.


Generated by Claude Code

…eliberate forwarding, no accidental erasure (#4548)

`SchemaRenderer` handed `forwardRef` a props type of
`{ schema: SchemaNode } & Record<string, any>`. A string index signature puts
`string` into `keyof Props`, so `'ref' extends keyof Props` is always true,
React's `PropsWithoutRef` takes its `Omit` branch, and `Omit` over a type
carrying a string index signature keeps only the index signature. Every
declared prop was erased.

Measured on the pre-fix source: `keyof ComponentProps<typeof SchemaRenderer>`
was `string` and `ComponentProps<typeof SchemaRenderer>['schema']` was `any`,
while the type argument went on declaring `SchemaNode`. `<SchemaRenderer />`
with no schema at all, `schema={12345}`, and arbitrary misspelled props each
type-checked in silence.

The forwarding surface is KEPT, deliberately: this is the renderer loop, it
forwards unread props to the component the schema names at runtime, and the
package README documents that. The two halves are separated instead — the
forwardRef type argument is the honest `SchemaRendererProps` (nothing for
`PropsWithoutRef` to collapse), and the open surface is stated once in an
explicit export annotation, which nothing routes through `Omit`.

`SchemaRendererProps.schema` is declared as `BaseSchema | string | null |
undefined` — what the component actually handles — replacing `@object-ui/core`'s
`SchemaNode` interface, which required `type: string` and contradicted the
component's own early returns for strings and nullish.

One declared behaviour change: a non-object, non-string primitive schema now
renders as its own text instead of falling through to `{ ...schema }`, which
spread a primitive to an empty object and surfaced the red "Unknown component
type: undefined" box.

Latent defects the erasure hid, fixed at their call sites: DashboardRenderer's
`Record<string, any>` cast dropped `type`; DashboardGridLayout's inferred union
admitted a typeless shape; ReportViewer handed a section's `content` ARRAY to
the renderer whole.

A repo-wide structural guard replaces the two per-package siblings' blocked
direction, with a detector that resolves `Record<string, ...>` and `string`-keyed
mapped types — the spelling both shipped guards went blind on. It judges the
forwardRef type argument only, never export annotations.

Refs #4422, #4438, #4528, #4551.
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 13, 2026 12:40pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index--oI-l4V_.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.33KB 108.47KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 163.56KB 44.83KB
fields (index.js) 230.37KB 57.17KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.10KB 17.67KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 121.04KB 31.57KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.93KB 60.01KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.30KB 40.02KB
plugin-grid (index.js) 189.37KB 50.33KB
plugin-kanban (index.js) 52.74KB 14.53KB
plugin-list (index.js) 111.13KB 27.12KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.29KB 11.05KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.09KB 20.56KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 27.64KB 9.44KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.26KB 0.67KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants