Skip to content

fix(types): DashboardRenderer and ListView serve their declared props — the index signature stops erasing them (#4528) - #4551

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4528-forwardref-prop-erasure
Aug 13, 2026
Merged

fix(types): DashboardRenderer and ListView serve their declared props — the index signature stops erasing them (#4528)#4551
yinlianghui merged 1 commit into
mainfrom
claude/issue-4528-forwardref-prop-erasure

Conversation

@yinlianghui

@yinlianghui yinlianghui commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Fixes #4528

Sweeps the two packages #4422 / PR #4438 left unswept. Both components declared a full props interface and neither was enforced.

Both survivors measured first

The card measured plugin-dashboard and asserted plugin-list by inspection. Both were probed on the pre-fix source before anything was edited, through each package's own tsconfig.test.json. They are identical — plugin-list's by-inspection claim is now measured:

plugin-dashboard/src/__probe4528__.test.ts(9,14):  Type 'keyof DashboardRendererProps' is not assignable to type 'never'.
plugin-dashboard/src/__probe4528__.test.ts(11,14): Type 'string | number' is not assignable to type 'never'.
plugin-dashboard/src/__probe4528__.test.ts(13,14): Type 'any' is not assignable to type 'never'.
plugin-dashboard/src/__probe4528__.test.ts(15,14): Type '((widgetId: string | null) => void) | undefined' is not assignable to type 'never'.

plugin-list/src/__probe4528__.test.ts(9,14):  Type 'keyof ListViewProps' is not assignable to type 'never'.
plugin-list/src/__probe4528__.test.ts(11,14): Type 'string | number' is not assignable to type 'never'.
plugin-list/src/__probe4528__.test.ts(13,14): Type 'any' is not assignable to type 'never'.
plugin-list/src/__probe4528__.test.ts(15,14): Type '((record: Record< string, unknown >) => void) | undefined' is not assignable to type 'never'.

Reading those: the interface declares real keys, the resolved call-site keyof is string | number, the named prop reads any, and the interface still declares the real signature. Declaration right, nobody held to it. The probes were throwaway and are not in this PR; the permanent pins are the two *.propsResolution.test.ts files.

The fix

The index signature is removed from both declared interfaces, so PropsWithoutRef takes its identity branch instead of its Omit branch. Visible directly in the shipped .d.ts — note the spaces after each <, which GitHub's body sanitizer requires:

- export declare const DashboardRenderer: ForwardRefExoticComponent< Omit< DashboardRendererProps, "ref" > & RefAttributes< HTMLDivElement > >;
+ export declare const DashboardRenderer: ForwardRefExoticComponent< DashboardRendererProps & RefAttributes< HTMLDivElement > >;

- export declare const ListView: React.ForwardRefExoticComponent< Omit< ListViewProps, "ref" > & React.RefAttributes< ListViewHandle > >;
+ export declare const ListView: React.ForwardRefExoticComponent< ListViewProps & React.RefAttributes< ListViewHandle > >;

That Omit< …, "ref" > is the erasure itself, materialized in the published artifact: Omit over a type carrying a string index signature keeps only the index signature.

The runtime pass-through is unchanged: each render function's parameter carries the signature instead (PR #4438's remedy), so ...props still collects arbitrary keys.

Props each component genuinely reads but never declared are now declared by name, at the type each lands on — dataSource on both, plus onAddRecord / onBulkAction / onPageSizeChange / onEdit / onDelete / onBulkDelete on ListView. DashboardRenderer's DOM pass-through keys are derived from toDomProps' whitelist constant itself, so declaration and runtime filter cannot drift — the direction @object-ui/core's dom-props doctrine already asks for: "Deliberate DOM pass-through beyond this set stays available the objectui#4435 way — DECLARE it and forward it by name. Do not reopen the spread."

Type-only, measured rather than asserted

The emitted JS is byte-identical before and after:

artifact sha256 (both)
plugin-dashboard/dist/index.js d31056cf85ae…
plugin-dashboard/dist/index.umd.cjs cf7ba45fa822…
plugin-list/dist/index.js 8a8c3d017a32…
plugin-list/dist/index.umd.cjs 738e2a86cbac…

Both packages' runtime suites are untouched: 87 files, 939 tests, all green.

Canary: the full repo-wide type-check

Baseline on origin/main was 80/80. After the fix it named exactly three latent defects the erasure had been hiding, and is 80/80 again with them fixed:

  1. DashboardWithConfig typed its handler (widgetId: string) while DashboardRenderer calls onWidgetClick(null) to deselect on a design-mode background click. The state behind it is already useState< string | null >, so widening the annotation is what the contract always said.
  2. InterfaceListPage built a list schema whose viewType was a bare string (allowedVisualizations arrives as string[]), never checked against ListViewSchema.
  3. StudioDesignSurface forwarded refreshKey to ListView, which no component in the chain declares or reads — it rode the {...props} forward and was dropped. Removed (behaviour-preserving); wiring it is a behaviour change, filed as Studio Data pillar ignores its renderListView slot's refreshKey — the prop was forwarded to a component that never declared it #4549.

No consumer relies on arbitrary-prop passthrough as a feature: every in-repo call site passes specific named props, no README or doc endorses the passthrough, and the one < ListView objectName= fields= navigation= /> in apps/console lives inside a template-literal string compiled at runtime by the kind:'react' tier, so the type system never saw it and cannot affect it.

Guards, with the discrimination proof

#4438's ratchet resolves its scan root as packages/components/src and structurally could not see either survivor. Per-package siblings are added, with a scope wider than the original's: the original judges only forwardRef calls whose render function destructures schema, which misses the public DashboardRenderer — it takes (props, ref) whole, and is precisely the call-site half this card measured.

Run against origin/main's shape (fix removed via patch file, restored and sha256-verified), they go red on both assertions, and the dashboard guard names both sites including the one the original's heuristic cannot see:

FAIL plugin-dashboard … > no forwardRef carries a string index signature on its props type argument
  + [ "DashboardRenderer.tsx:192", "DashboardRenderer.tsx:1057" ]
FAIL plugin-dashboard … > every destructuring forwardRef annotates its props parameter
  + [ "DashboardRenderer.tsx:192" ]
FAIL plugin-list … > no forwardRef carries a string index signature on its props type argument
  + [ "ListView.tsx:634" ]
FAIL plugin-list … > every destructuring forwardRef annotates its props parameter
  + [ "ListView.tsx:634" ]

Test Files  2 failed (2)
     Tests  4 failed | 4 passed (8)

Against this branch: 8 passed (8). The type-level pins fail the same way on the pre-fix shape (6 assertions red in plugin-dashboard, 10 in plugin-list).

#4528 direction 3 — one guard over every package src — is deliberately NOT done here and is blocked, not skipped: a repo-wide widening goes red on packages/react/src/SchemaRenderer.tsx, which is outside this card's surface. Both guard headers say so and point at the finding.

Grading: minor, not major

Quoting the ruling on #4528, which the changeset also carries:

the interfaces have always DECLARED these props; the index signature erased them from the resolved type. Restoring what the interface documents is a FIX to the published contract, not a contract break — no documented capability is removed, and any-typed accidental passthrough was never the documented surface.

Findings filed

Verification

  • turbo run type-check (full, as CI runs it): 80/80, baseline-matching
  • both packages' tsc -p tsconfig.json and -p tsconfig.test.json: clean
  • vitest run packages/plugin-dashboard/ packages/plugin-list/: 87 files, 939 tests green
  • vitest run app-shell consumer suites: 22 files, 143 tests green
  • turbo run lint on the three touched packages: 0 errors
  • check-control-bytes / check-changeset-presence / check-changeset-no-major / check-changeset-fixed / check-phantom-dependencies: all green

Left as draft for PM step-7 review.


Generated by Claude Code

… — the index signature stops erasing them (#4528)

Both components declared a full props interface and neither was enforced. A
`[key: string]: any` on `DashboardRendererProps` and `ListViewProps` 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 property was erased on both sides: the render function received
`{ [x: string]: any }` (so even `schema` was `any` inside the component) and
every JSX call site was unchecked.

Measured on the pre-fix source: `keyof ComponentProps<typeof DashboardRenderer>`
was `string | number` and `...['onWidgetClick']` was `any`, while the interface
declared `(widgetId: string | null) => void`. plugin-list, asserted by
inspection in the card, measured identically for `onRowClick`.

Type-only: the emitted JS for both packages is byte-identical before and after
(sha256 on dist/index.js and dist/index.umd.cjs), and both runtime suites are
untouched and green (87 files, 939 tests).

Props each component genuinely reads but never declared are now declared by
name at the type each lands on. DashboardRenderer's DOM pass-through keys are
derived from `toDomProps`' whitelist constant so declaration and runtime filter
cannot drift.

Three latent defects the erasure hid, each surfaced by the repo-wide canary:
DashboardWithConfig typed its handler `(widgetId: string)` while the renderer
calls `onWidgetClick(null)` to deselect; InterfaceListPage built a schema whose
`viewType` was a bare `string`; StudioDesignSurface forwarded a `refreshKey`
that nothing in the chain declares or reads.

Per-package structural guards pin the shape, covering the public `forwardRef`
that takes its props whole — the spelling #4438's schema-destructuring scan
could not see.

Refs #4422, #4438, #4426, #4040. Findings filed: #4548, #4549, #4550.
@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 8:00am

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-CFmxgVt1.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.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 158.47KB 43.13KB
fields (index.js) 230.18KB 57.13KB
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) 120.95KB 31.53KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.88KB 59.99KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.26KB 40.01KB
plugin-grid (index.js) 189.34KB 50.32KB
plugin-kanban (index.js) 48.62KB 13.42KB
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.16KB 10.96KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 20.55KB
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) 23.73KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
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

Copy link
Copy Markdown
Collaborator Author

PM step-7 复核 — ACCEPT (session_017Qqyix2QcnpUC9XeYVDzx3)

Auto-merge armed (squash) — landing verified per the merge-queue discipline.


Generated by Claude Code


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 13, 2026 08:14
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit 7084f7d Aug 13, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4528-forwardref-prop-erasure branch August 13, 2026 08:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-dashboard, plugin-list): the two packages #4422 left unswept still erase every declared prop — and the #4438 guard cannot see them

2 participants