Skip to content

fix(plugin-dashboard): the widget grid passes only whitelisted DOM props to its container (#4432) - #4491

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4432-dashboard-grid-whitelist
Aug 12, 2026
Merged

fix(plugin-dashboard): the widget grid passes only whitelisted DOM props to its container (#4432)#4491
yinlianghui merged 1 commit into
mainfrom
claude/issue-4432-dashboard-grid-whitelist

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Closes #4432

Migration step 2 of the #4425 phase-2 ruling (comment): option 1 — promote the whitelist to the SDUI widget contract. It follows #4431 / PR #4485 exactly and consumes the shared toDomProps executor that PR lifted to @object-ui/core. No new dependency: plugin-dashboard already declares @object-ui/core.

The defect

view:dashboard resolves to DashboardRenderer, whose widget-grid container ended in a bare {...props} spread (packages/plugin-dashboard/src/DashboardRenderer.tsx). SchemaRenderer hands a registered component the authored node's own keys, the contents of its props container, the ARIA it resolved, the evaluated disabled verdict and the host's trailing props — so all of it became attributes, because React passes unknown lowercase attributes through in silence and stringifies object values. Measured through the real SDUI path with an adapter attached: 13 non-DOM attributes.

datasource is not among them — this component destructures the adapter and hands it to its own SchemaRenderer calls, so it never reached the spread. That is the one key PR #4428 shipped a pass without, and it is pinned here anyway (see below).

Both layout branches spread onto the same host element, so both convert: the responsive desktop grid and the mobile stack.

Red-first, three directions

1. The defect — pre-fix, with the ledger row temporarily removed so the leak is visible (git checkout -- to restore, never git stash):

FAIL packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx > view:dashboard
+ view:dashboard leaked 13 non-DOM attribute(s):
+   div leaked name="canary_node"
+   div leaked bind="data.revenue"
+   div leaked events="[object Object]"
+   div leaked arialabel="Canary label"
+   div leaked ariadescribedby="canary-desc"
+   div leaked zzcanary="CANARY-STR"  … zzcanaryobj / zzcanarynum / zzcanarycamel
+   div leaked reference_to="contacts"
+   div leaked props="[object Object]"
+   div leaked colorvariant="success" … zzcanaryprop="CANARY-PROP"
 Tests  1 failed | 38 passed (39)

(the tag name is unbracketed above only because the stored body strips a bracket followed by a letter)

Baseline first, on the unmodified tree with all rows present: Test Files 1 passed (1) · Tests 39 passed (39).

2. The stale-row ratchet — post-fix, with the view:dashboard row still in place. This is the direction that makes the row expire with the defect:

AssertionError: expected [] to deeply equal [ 'ariadescribedby', …(12) ]
- [ "ariadescribedby", "arialabel", "bind", "colorvariant", "events", "name",
-   "props", "reference_to", "zzcanary", "zzcanarycamel", "zzcanarynum",
-   "zzcanaryobj", "zzcanaryprop" ]
+ []
 Tests  1 failed | 38 passed (39)

Row deleted in this PR → Test Files 1 passed (1) · Tests 39 passed (39).

3. The exact-set pin. packages/plugin-dashboard/src/__tests__/DashboardRenderer.domProps.test.tsx renders the dashboard through SchemaRendererProvider with a data-source adapter attached — a schema-only fixture cannot see datasource, which is how #4428 shipped a six-key first pass — and asserts the container's attribute set exactly, so a key that stops being delivered is as red as a key that leaks. Reverse-verified by removing the fix and re-running: all 5 cases go red, the set case reporting the full 13 back on the element.

AssertionError: expected [ 'aria-describedby', …(21) ] to deeply equal [ 'aria-describedby', …(8) ]
+   "ariadescribedby", "arialabel", "bind", "colorvariant", "events", "name",
+   "props", "reference_to", "zzcanary", "zzcanarycamel", "zzcanarynum",
+   "zzcanaryobj", "zzcanaryprop"

The delivered set, asserted with values: aria-describedby, aria-label, class, data-obj-id, data-obj-type, id, role, style, tabindex. Plus the negative sweep of the 13 named keys, schema / datasource by name, a generic "no attribute value contains [object Object]" assertion, and the ARIA pair — camelCase arialabel / ariadescribedby gone while the resolved aria-label / aria-describedby remain with their values.

Behaviour edges, stated exactly (the #4485 disabled-precedent check)

The old spread was trailing, so it silently overrode every computed prop on this container. Three things move, none of them silently:

  • onClick now has ONE carrier. It is a declared DOM pass-through key of the contract and this container computes its own design-mode background handler (handleBackgroundClick, which deselects on a background click). The trailing spread resolved that collision by letting the incoming handler replace the computed one, so a host that passed onClick silently lost background deselection. Dropping the incoming handler instead would have been the mirror failure — a whitelisted key that type-checks, reads as supported, and never arrives. Both run now, container affordance first, and both directions are pinned. A non-function onClick (SDUI spells click behaviour events: { onClick: [ActionDef] }, which is DATA and is dropped by the whitelist) is ignored rather than handed to React, which used to throw Expected 'onClick' listener to be a function, instead got a value of 'string' type. — also pinned.
  • An authored style no longer replaces the computed grid layout. style is not in the SDUI pass-through set, and this container computes its own gridTemplateColumns / gridAutoRows / gap; an authored style used to overwrite all of it and collapse the grid. It is now dropped.
  • An authored data-user-actions no longer overrides the value computed from the userActions prop. The data-* family still passes the whitelist; only this one collision with a computed attribute resolves the other way now.

The injected disabled verdict is dropped rather than forwarded. Unlike #4485's chatbot case there is no verdict to consume here: nothing in this component ever read it, so it only ever became a disabled attribute on a container element that has no such attribute. That is the leak, not a behaviour.

Header truth-up (routed here from PR #4485's review)

The sweep file's docblock still carried the phase-1 reading (plugin-chatbot | 3 | 2 | 14 each, plugin-dashboard | 8 | 3 | 7 / 9 / 13) and the sentence "5 of 23 targets leak" — both false after #4485 and after this PR. The table now reads:

package targets targets leaking leaked attributes
plugin-charts 9 0 0
plugin-calendar 3 0 0
plugin-chatbot 3 0 0
plugin-dashboard 8 2 7 / 9

2 of 23 targets leak, and both are exactly what the ledger holds after this PR: plugin-dashboard:metric and plugin-dashboard:metric-card, the open tail #4425 owns directly. Their rows are untouched. The header also now records that phase 2 is ruled (so the gate is the migration's ratchet rather than the measurement it was waiting for), which two steps closed which rows, and that the three packages reading 0 are not clean for the same reason — charts never spread, calendar takes a declared prop list, chatbot and this grid reach zero by filtering. The "divergence" section says plainly that plugin-dashboard is now MIXED by design: this container filters through toDomProps while the two KPI components still run the schemaHostProps deny-list, and the two surviving rows are precisely that difference.

Verification

  • pnpm exec vitest run packages/plugin-dashboard/ packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsxTest Files 46 passed (46) · Tests 410 passed (410).
  • type-check for @object-ui/plugin-dashboard and @object-ui/app-shell (both tsc projects — tsc --noEmit and tsc -p tsconfig.test.json, which is what compiles the sweep file) — all Done, after building each package's dependency closure first.
  • lint for the same two — 0 errors. The first draft's useCallback on the composed handler drew two Compilation Skipped: Existing memoization could not be preserved React Compiler warnings; it closes over props.onClick read out of the rest object, which the compiler cannot prove stable, so it is a plain handler now (0 new warnings; the file lands on a plain element with no memoized child).
  • check-control-bytes (4179 files), check-changeset-presence, check-changeset-no-major, check-phantom-dependencies, check-lint-coverage, check-type-check-coverage — all green.

Changeset: @object-ui/plugin-dashboard patch, graded by measurement rather than assumption — the package was built with and without the fix and the emitted dist/*.d.ts are byte-identical in both directions, so this is behaviour-only. No public type or export moved.


Generated by Claude Code

…ops to its container (#4432)

`view:dashboard` resolves to `DashboardRenderer`, so `SchemaRenderer` hands it
the dashboard node's own keys, the contents of the node's `props` container, the
resolved ARIA and the host's trailing props — and every key the component did
not destructure was spread raw onto the widget-grid container. Measured through
the real SDUI path: 13 non-DOM attributes.

Migration step 2 of the #4425 phase-2 ruling (option 1 — the `toDomProps`
whitelist is the SDUI widget contract), following #4431 / PR #4485's shape and
consuming the `toDomProps` executor that PR lifted to `@object-ui/core`.

Both layout branches convert; the spread goes FIRST so the component's own
computed attributes stay authoritative. `onClick` — a whitelisted key the
container also computes — now has one carrier instead of being replaced by the
trailing spread. The `view:dashboard` ledger row is deleted and the sweep gate's
header reading table is brought to current truth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 12, 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 12, 2026 8:45pm

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-BYYbwCSH.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) 22.10KB 4.37KB
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) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
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) 4.91KB 0.87KB
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) 153.42KB 41.19KB
fields (index.js) 230.07KB 57.07KB
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.33KB 1.20KB
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) 45.46KB 12.52KB
plugin-charts (index.js) 62.01KB 17.63KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 120.85KB 31.41KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.03KB 59.77KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 188.13KB 50.00KB
plugin-kanban (index.js) 48.62KB 13.42KB
plugin-list (index.js) 110.47KB 26.90KB
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.21KB 7.52KB
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

ACCEPT — step-7 复核 by PM session session_017Qqyix2QcnpUC9XeYVDzx3 (focused review).

Flipping ready + arming auto-merge. #4434 (judge unification) unlocks on this landing — dispatching next with a poll-gate.


Generated by Claude Code


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 12, 2026 20:59
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 0bf3f44 Aug 12, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4432-dashboard-grid-whitelist branch August 12, 2026 20:59
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.

plugin-dashboard: DashboardRenderer's widget grid spreads the whole SDUI node onto its container — 13 non-DOM attributes reach the DOM

2 participants