Skip to content

fix(components): whitelist ui:grid's DOM passthrough so schema keys stop leaking as attributes - #5573

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-4787-grid-dom-attribute-leak
Aug 21, 2026
Merged

fix(components): whitelist ui:grid's DOM passthrough so schema keys stop leaking as attributes#5573
os-sales merged 2 commits into
mainfrom
claude/issue-4787-grid-dom-attribute-leak

Conversation

@os-sales

@os-salesos-sales commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Fixes#4787

Note on spelling: element tags below are written with a space after < and before > (< div … >), the same workaround #3291's body uses. GitHub's body sanitizer eats angle-bracket-delimited runs even inside fenced code blocks — the first revision of this description lost the entire DOM measurement to it.

The defect, measured

grid.tsx ended in a bare {...gridProps} spread onto its < div >, removing only data-obj-* and style. Everything else SchemaRenderer hands a registered component — the authored node's own keys, the contents of its props container, and any extra key the author wrote — landed on the element as invalid HTML attributes. Measured on origin/main with the canary node the new test uses:

< div class="grid grid-cols-4 sm:grid-cols-2 md:grid-cols-2 gap-4 authored-class"
columns="4" gap="4" mdcolumns="2" smcolumns="2" id="grid-node" name="grid_node"
props="[object Object]" zzcanary="leak" data-testid="g" aria-label="A grid"
role="region" colorvariant="x" data-obj-id="grid-node" data-obj-type="grid" >

Eight illegitimate attributes: columns, gap, mdcolumns, smcolumns, name, props, zzcanary, and colorvariant (the flattened props container). A responsive columns object renders as columns="[object Object]". Layout is unaffected, which is why every catalog grid example has been rendering with them.

The fix — whitelist, per triage's ruling

The spread now goes through toDomProps from @object-ui/core: the whitelist #3291 established in packages/fields and #4425 phase 2 promoted to the SDUI widget contract. This is the existing mechanism, not a second spelling of it — grid.tsx is now one more caller of the same executor that plugin-chatbot and DashboardRenderer already use.

Keys that are declared DOM-safe survive (id, className, role, tabIndex, autoFocus, the React synthetic handlers) plus the open data-* / aria-* families — which is how the designer's data-obj-id / data-obj-type still arrive, so the two hand-forwarded lines they used to need are gone. style continues to be forwarded by name (the #4435 route): it is this container's designer sizing channel, but the shared set is deliberately element-agnostic and nothing element-specific belongs in it.

Enumerating today's GridSchema keys to strip would have re-rotted on the next schema addition, and could never have reached the open tail — zzcanary and the props container are author-supplied, so no finite list names them.

Grid's own vocabulary was always read off schema, never off these props, so no authored input and no rendered layout changes.

Why React's unknown-attribute warning never turned a test red

Triage asked for this, and it has four independent answers, each sufficient on its own. The card's premise — that there is an "unknown-lowercase-attribute warning" being swallowed — turns out to be the least of it.

1. For most of the leak, including both attributes the card's title names, React emits no warning at all. Since React 16, unknown all-lowercase attributes are passed straight to the DOM by design, silently, with object values stringified. So columns, gap, name, props, zzcanary produce zero console output. Measured: eight leaked attributes, three console.error calls. There is no lowercase warning to swallow — React does not have one.

2. The warning that does fire is the camelCase one, for mdColumns / smColumns / lgColumns / xlColumns / colorVariant:

React does not recognize the mdColumns prop on a DOM element. If you intentionally want it to appear in the DOM as a custom attribute, spell it as lowercase mdcolumns instead.

Note the trap in React's own remedy: spelling it lowercase silences the warning while keeping the leak — it converts case 2 into case 1.

3. Vitest 4's reporter defaults to silent: 'passed-only', so console output from a passing test is discarded. Measured directly — two tests in one file, identical console.error, only the failing one's output survives:

stderr | zz-sink.test.ts > sink probe > FAILING test logs
MARKER-FROM-FAILING-TEST

MARKER-FROM-PASSING-TEST appears nowhere in the run output. This makes the warning structurally incapable of turning a test red: to be seen at all, something else must have already failed. A grid that renders fine while leaking attributes is precisely the passing case.

4. React latches the warning per prop name per module instance, so it fires only on the first render carrying a given key. This is not theory — it bit this PR. Case 3 of the new test, written against the shared canary, passed against the leaking renderer, because cases 1 and 1b render first and consume the latch. Reverse-verification caught it green on code leaking ten attributes; it now authors its own zzCanaryCamel key, used nowhere else in the file, and goes red as it should.

There is also no console.error-as-failure pin anywhere in vitest.setup.base.ts / vitest.setup.dom-light.tsx / vitest.setup.dom.tsx, so even a printed warning would only be log noise.

Recommendation: a warning-as-error pin is not the right guard here

I recommend against it as the answer to this class, for one decisive reason: it is blind to the majority of this very defect. It can only ever catch case 2 — five of the ten leaked attributes here, and neither of the two the card's title names. A green warning-as-error pin would read as "this class is closed" while columns="[object Object]" sailed past it untouched. That is a phantom check, and reasons 3 and 4 above mean it would also be order- and reporter-sensitive.

The mechanism that actually closes the class is the one this repo already built: read the DOM, not the console.packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx checks every rendered attribute against what HTML defines, and catches lowercase and camelCase alike. Case 1 of the new test applies that technique to this renderer.

The real gap is that the sweep's targets are four plugin packages and it does not reach the packages/components renderers at all — which is why ui:grid leaked unobserved through #4008's ratchet and #4011's implementation. Extending it there is the pin worth having, it would have caught this, and it lands outside this card's file surface, so it is filed rather than built: #5574 (sub-issue of #4425), which also records that flex.tsx, stack.tsx, container.tsx and text.tsx carry the identical unfixed spread.

Tests

packages/components/src/__tests__/grid-dom-attribute-whitelist.test.tsx, four cases.

Case 1 sweeps every attribute on the element against the declared DOM-safe set rather than naming today's bad keys — so a key added to GridSchema tomorrow is covered without editing the test, and the open tail is covered at all. Case 1b covers the responsive columns object. Case 2 is a positive control: the computed grid classes, the authored className merged with them, id, role, aria-*, data-* including data-obj-id/data-obj-type, the forwarded style, and the children. Case 3 is the React-warning ratchet described above, kept explicitly labelled as the weaker half.

Reverse-verification (fix reverted to origin/main, mutation confirmed on disk by grep -c toDomProps = 0 / gridProps = 2, restored via an EXIT trap):

× case 1 — expected [ 'columns', 'gap', 'smcolumns', …(7) ] to deeply equal []
× case 1b — expected true to be false (columns="[object Object]")
× case 3 — React unknown-prop warnings present
✓ case 2 — positive control PASSES against the broken code
Tests 3 failed | 1 passed (4)

Case 2 staying green against the leaking renderer is the point: it proves it is a control, not the detector — a fix that stripped everything would fail it while cases 1/1b passed.

Gates

All run at 020140073 (the final commit), from the worktree root:

gateresult
pnpm --filter @object-ui/components type-checkexit 0 — tsc --noEmit && tsc -p tsconfig.test.json
pnpm --filter @object-ui/components lintexit 0 — 896 problems (0 errors, 896 warnings), all pre-existing react-refresh/only-export-components
pnpm exec vitest run packages/components/exit 0 — Test Files 174 passed (174), Tests 1576 passed (1576)
check:control-bytes✅ check-control-bytes: OK (scanned 4630 tracked text file(s); skipped 85 binary)
check:doc-types✅ Every documented component type is registered.
check-changeset-presence✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-major✅ No changeset declares a major bump.
check-changeset-fixed✅ All workspace packages are in the changeset fixed group.

Declared narrowing, two items — read these as narrowing, not as passes:

  • check:doc-snippets was NOT run. It refuses to run on this worktree: @object-ui/plugin-view … is not on disk — run the build first, 11 packages unbuilt, and it says so explicitly (The snippet program was NOT run). Building their full closures is effectively a whole-workspace build, which CI does anyway. It cannot observe this diff regardless: it is a type-level gate over doc snippets compiled against built package types, and grid.tsx has zeroexport statements — it is a pure side-effect registration module (import './grid'), contributing nothing to any package's .d.ts. check:doc-types, the gate that actually checks documented component types against the registry, did run and is green.
  • Repo-wide pnpm lint / pnpm type-check (turbo run …) were scoped to @object-ui/components. The package's own lint ran over the whole package, so there is no file-level narrowing within it; the narrowing is at package level, and it is sound because the diff touches only the packages/components tree plus .changeset/, and eslint.config.js enables no type-aware linting (no parserOptions.project, no projectService), so this diff cannot move any untouched package's verdict.

Also swept: no snapshot or fixture anywhere in the repo pinned the leaked spellings (grep -rn "mdcolumns|smcolumns|lgcolumns|xlcolumns" outside the changed files returns nothing), so no fixture triage was needed.


Generated by Claude Code

…top leaking as attributes (#4787)
`grid.tsx` ended in a bare `{...gridProps}` spread that removed only `data-obj-*`
and `style`, so every other key `SchemaRenderer` hands a registered component
reached the rendered `<div>`. Measured on a canary node, eight invalid HTML
attributes leaked, including `columns="[object Object]"` and `mdcolumns="2"`.
The spread now goes through `toDomProps` from `@object-ui/core` — the whitelist
objectui#3291 established and objectui#4425 phase 2 promoted to the SDUI widget
contract — rather than enumerating today's schema keys to strip, which re-rots on
the next schema addition and can never reach author-supplied keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK
…4787)
Written against the shared canary, case 3 PASSED against the leaking renderer:
React latches its unknown-prop warning per prop name per module instance, so
cases 1/1b consume the latch first and the spy sees nothing. Reverse-verification
caught it green on code that leaked ten attributes. It now authors its own
camelCase key (`zzCanaryCamel`), used nowhere else in the file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3784.8 KB3867.2 KB
Main entry chunk (gzip)151.2 KB350 KB
Entry fileindex-CQi90FAJ.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)8.91KB2.99KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.88KB113.68KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.33KB
fields (index.js)237.61KB59.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.22KB3.08KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)30.51KB7.57KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.72KB18.35KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.51KB32.96KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)242.15KB60.89KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.07KB30.43KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.70KB27.17KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.48KB20.67KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

Sign up for freeto 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] grid renderer 把 schema 键原样漏成 DOM 属性(columns="[object Object]"、mdcolumns="2" 等无效 HTML 属性)

2 participants

@os-sales@claude