Skip to content

Commit cb4f4fb

Browse files
committed
fix(components): whitelist ui:grid's DOM passthrough so schema keys stop 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
1 parent ac73c24 commit cb4f4fb

3 files changed

Lines changed: 272 additions & 14 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@object-ui/components': patch
3+
---
4+
5+
The `ui:grid` renderer now forwards to the DOM by whitelist, so schema keys no longer
6+
land on the rendered `<div>` as invalid HTML attributes (objectui#4787).
7+
8+
`grid.tsx` ended in a bare `{...gridProps}` spread that removed only `data-obj-*` and
9+
`style`, so everything else `SchemaRenderer` hands a registered component reached the
10+
element. Measured on a canary node, eight attributes leaked:
11+
`columns="4"`, `gap="4"`, `mdcolumns="2"`, `smcolumns="2"`, `name="grid_node"`,
12+
`props="[object Object]"`, `colorvariant="x"` (the flattened `props` container) and an
13+
unknown authored `zzcanary="leak"`. A responsive `columns` object rendered as
14+
`columns="[object Object]"`. Layout was unaffected, so every catalog grid example
15+
rendered with them — the reason this went unnoticed.
16+
17+
The spread now goes through `toDomProps` from `@object-ui/core`, the same whitelist
18+
objectui#3291 established in `packages/fields` and objectui#4425 phase 2 promoted to the
19+
SDUI widget contract. Keys that are *declared* DOM-safe survive — `id`, `className`,
20+
`role`, `tabIndex`, plus the open `data-*` and `aria-*` families, which is how the
21+
designer's `data-obj-id` / `data-obj-type` still arrive — and `style` continues to be
22+
forwarded by name. Nothing an author can add to a grid node reaches the DOM implicitly
23+
any more, including keys `GridSchema` does not have yet; enumerating today's keys to
24+
strip would have re-rotted on the next schema addition.
25+
26+
No authored input changes and no layout changes: the grid's own vocabulary was always
27+
read off `schema`, never off these props.
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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+
/**
10+
* `ui:grid` forwards to the DOM by WHITELIST (objectui#4787).
11+
*
12+
* ## What is being observed, and why the obvious test is worthless here
13+
*
14+
* The observable is the RENDERED DOM, not the React tree. `SchemaRenderer` spreads
15+
* the authored node onto the registered component, and `grid.tsx` used to end in a
16+
* bare `{...gridProps}` spread onto its `<div>`, so the node's own vocabulary landed
17+
* as invalid HTML attributes. Measured on `origin/main` with the canary node below:
18+
*
19+
* ```
20+
* <div class="grid grid-cols-4 …" columns="4" gap="4" mdcolumns="2" smcolumns="2"
21+
* id="grid-node" name="grid_node" props="[object Object]" zzcanary="leak"
22+
* data-testid="g" aria-label="A grid" role="region" colorvariant="x"
23+
* data-obj-id="grid-node" data-obj-type="grid"></div>
24+
* ```
25+
*
26+
* Every one of those renders. "The grid renders" is GREEN against the broken code —
27+
* every catalog grid example rendered today, wrong attributes and all. So the
28+
* assertion has to be that the offending attributes are ABSENT from the element.
29+
*
30+
* ## Why case 1 sweeps every attribute instead of naming the bad ones
31+
*
32+
* Naming `columns` / `gap` / `mdcolumns` would pass just as well against a renderer
33+
* that strips those four and keeps leaking the rest, and it would re-rot the moment
34+
* `GridSchema` grows a key — the exact staleness this defect IS. It also could never
35+
* reach the OPEN TAIL: `zzcanary` and the flattened `props` container are
36+
* author-supplied, so no finite list of schema keys names them. Case 1 therefore
37+
* asserts the whole attribute set against what the contract DECLARES as DOM-safe, so
38+
* a key added to `GridSchema` tomorrow is covered without editing this file.
39+
*
40+
* ## The positive control is load-bearing
41+
*
42+
* A renderer that stripped EVERYTHING would satisfy the absence assertions on its
43+
* own. Case 2 pins what must still arrive — the computed grid classes, the authored
44+
* `className` merged with them, `id`, `role`, the `data-*` / `aria-*` families
45+
* (including the designer's `data-obj-id` / `data-obj-type`), the forwarded `style`,
46+
* and the children — so "strips everything" fails here even while case 1 passes.
47+
*/
48+
49+
import { describe, it, expect, vi } from 'vitest';
50+
import { render } from '@testing-library/react';
51+
import { SchemaRenderer } from '@object-ui/react';
52+
// Registered at module scope, NOT in a `beforeAll` — there the cold transform is
53+
// billed to the narrower `hookTimeout` (AGENTS.md 测试纪律, objectui#3010/#3021).
54+
import '../renderers';
55+
56+
/**
57+
* Attribute names legal on the `<div>` this renderer produces: the exact keys
58+
* `@object-ui/core`'s SDUI pass-through set declares (lowercased, as the DOM stores
59+
* them — `className` becomes `class`, `tabIndex` becomes `tabindex`), plus `style`,
60+
* which `grid.tsx` forwards by name as its designer sizing channel.
61+
*/
62+
const ALLOWED_EXACT = new Set(['class', 'id', 'role', 'tabindex', 'autofocus', 'style']);
63+
64+
/** `data-*` and `aria-*` are open families in HTML and in the widget contract. */
65+
const isAllowedAttribute = (name: string): boolean =>
66+
ALLOWED_EXACT.has(name) || name.startsWith('data-') || name.startsWith('aria-');
67+
68+
/**
69+
* A grid node carrying, deliberately, one of every category the leak drew from:
70+
* the renderer's own vocabulary in both `columns` spellings, SDUI node metadata,
71+
* the `props` container, an unknown authored key, and the legitimately forwarded
72+
* DOM channels.
73+
*/
74+
const CANARY_NODE = {
75+
type: 'grid',
76+
// Renderer vocabulary — CONSUMED off `schema`, never forwarded.
77+
columns: 4,
78+
gap: 4,
79+
smColumns: 2,
80+
mdColumns: 2,
81+
lgColumns: 3,
82+
xlColumns: 6,
83+
// SDUI node metadata `SchemaRenderer` hands down.
84+
name: 'grid_node',
85+
// The `props` container, whose contents get flattened onto the component.
86+
props: { colorVariant: 'x' },
87+
// The open tail: an authored key nothing declares.
88+
zzcanary: 'leak',
89+
// Legitimate DOM channels — the positive control.
90+
id: 'grid-node',
91+
className: 'authored-class',
92+
role: 'region',
93+
'aria-label': 'A grid',
94+
'data-testid': 'grid-el',
95+
children: [],
96+
} as const;
97+
98+
function renderCanary(overrides: Record<string, unknown> = {}) {
99+
const { container } = render(
100+
<SchemaRenderer schema={{ ...CANARY_NODE, ...overrides } as never} />,
101+
);
102+
const el = container.firstElementChild as HTMLElement;
103+
expect(el, 'the canary grid must render at all').not.toBeNull();
104+
return el;
105+
}
106+
107+
const attributeNames = (el: HTMLElement) => Array.from(el.attributes).map((a) => a.name);
108+
109+
describe('ui:grid — schema keys never reach the DOM (objectui#4787)', () => {
110+
it('case 1: no attribute outside the declared DOM-safe set survives', () => {
111+
const el = renderCanary();
112+
113+
// The whole set, judged against the contract rather than against a list of
114+
// today's bad keys. The failure message carries the leaked names.
115+
expect(attributeNames(el).filter((n) => !isAllowedAttribute(n))).toEqual([]);
116+
117+
// The named regressions from the card, spelled out so a future failure reads as
118+
// this defect rather than as an anonymous set difference. `mdColumns` reaches the
119+
// DOM lowercased (HTML attribute names are case-insensitive), which is why the
120+
// card records it as `mdcolumns`.
121+
for (const leaked of [
122+
'columns', 'gap', 'smcolumns', 'mdcolumns', 'lgcolumns', 'xlcolumns',
123+
'name', 'props', 'zzcanary', 'colorvariant',
124+
]) {
125+
expect(el.hasAttribute(leaked), `"${leaked}" must not reach the DOM`).toBe(false);
126+
}
127+
128+
// The headline symptom: an object stringified onto an attribute. Assert on the
129+
// serialized element so this cannot pass by the attribute merely being renamed.
130+
expect(el.outerHTML).not.toContain('[object Object]');
131+
});
132+
133+
it('case 1b: a responsive `columns` OBJECT does not become columns="[object Object]"', () => {
134+
// The flat `smColumns`/`mdColumns`/… keys deliberately OVERRIDE the object form
135+
// in this renderer, so they are cleared here to exercise the object path itself.
136+
const el = renderCanary({
137+
columns: { xs: 1, md: 3 },
138+
smColumns: undefined, mdColumns: undefined, lgColumns: undefined, xlColumns: undefined,
139+
});
140+
141+
expect(el.hasAttribute('columns')).toBe(false);
142+
expect(el.outerHTML).not.toContain('[object Object]');
143+
expect(attributeNames(el).filter((n) => !isAllowedAttribute(n))).toEqual([]);
144+
// Still CONSUMED: the object form drives the classes it always did.
145+
expect(el.className).toContain('grid-cols-1');
146+
expect(el.className).toContain('md:grid-cols-3');
147+
});
148+
149+
it('case 2: positive control — everything legitimately forwarded still arrives', () => {
150+
const el = renderCanary({
151+
style: { minHeight: '10px' },
152+
children: [{ type: 'text', content: 'child-marker' }],
153+
});
154+
155+
// Computed layout classes — the renderer's real output, merged with the
156+
// authored className. A filter that dropped `className` would fail here.
157+
expect(el.className).toContain('grid');
158+
expect(el.className).toContain('sm:grid-cols-2');
159+
expect(el.className).toContain('md:grid-cols-2');
160+
expect(el.className).toContain('gap-4');
161+
expect(el.className).toContain('authored-class');
162+
163+
// Declared global attributes.
164+
expect(el.getAttribute('id')).toBe('grid-node');
165+
expect(el.getAttribute('role')).toBe('region');
166+
167+
// The two open families, including the designer channel that used to be
168+
// hand-forwarded and now arrives through the `data-*` prefix rule.
169+
expect(el.getAttribute('aria-label')).toBe('A grid');
170+
expect(el.getAttribute('data-testid')).toBe('grid-el');
171+
expect(el.getAttribute('data-obj-id')).toBe('grid-node');
172+
expect(el.getAttribute('data-obj-type')).toBe('grid');
173+
174+
// `style`, forwarded by name.
175+
expect(el.style.minHeight).toBe('10px');
176+
177+
// And the container still contains things.
178+
expect(el.textContent).toContain('child-marker');
179+
});
180+
181+
/**
182+
* The card's side-question, pinned as far as it can honestly be pinned.
183+
*
184+
* React DOES warn here — but only for the CAMEL-CASE keys (`mdColumns`,
185+
* `smColumns`, `colorVariant`): "React does not recognize the `%s` prop on a DOM
186+
* element. If you intentionally want it to appear in the DOM as a custom
187+
* attribute, spell it as lowercase `%s` instead." Measured on `origin/main`: eight
188+
* attributes leaked, three `console.error` calls. The all-lowercase ones —
189+
* `columns`, `gap`, `name`, `props`, `zzcanary`, and both attributes the card's
190+
* title names — produce NO warning at all, because React passes unknown lowercase
191+
* attributes through by design and stringifies object values silently.
192+
*
193+
* So this case is the WEAKER half and is kept only as a noise-floor ratchet on this
194+
* renderer: it can catch a camelCase key being reopened, and it is blind to the
195+
* majority of the very defect it accompanies. Case 1 is what actually closes the
196+
* class, because it reads the DOM instead of the console. Do not let this case's
197+
* green be read as coverage.
198+
*
199+
* Note it must SPY rather than rely on the run's output: Vitest 4's reporter
200+
* defaults to `silent: 'passed-only'`, so console output from a passing test is
201+
* discarded — which is the third reason this warning could never have turned a
202+
* test red on its own. See the PR body for the full answer.
203+
*/
204+
it('case 3: React emits no unknown-prop warning for this renderer any more', () => {
205+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
206+
try {
207+
renderCanary();
208+
const unknownPropWarnings = errSpy.mock.calls.filter((args) =>
209+
/does not recognize the .* prop on a DOM element/.test(String(args[0])),
210+
);
211+
expect(unknownPropWarnings).toEqual([]);
212+
} finally {
213+
errSpy.mockRestore();
214+
}
215+
});
216+
});

‎packages/components/src/renderers/layout/grid.tsx‎

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9-
import { ComponentRegistry } from '@object-ui/core';
9+
import { ComponentRegistry, toDomProps } from '@object-ui/core';
1010
import type { GridSchema } from '@object-ui/types';
1111
import { renderChildren } from '../../lib/utils';
1212
import { cn } from '../../lib/utils';
@@ -102,21 +102,36 @@ ComponentRegistry.register('grid',
102102
className
103103
);
104104

105-
// Extract designer-related props
106-
const {
107-
'data-obj-id': dataObjId,
108-
'data-obj-type': dataObjType,
109-
style,
110-
...gridProps
111-
} = props;
105+
// DOM pass-through is a WHITELIST, never a list of keys to strip — objectui#3291's
106+
// discipline, promoted out of `packages/fields` to `@object-ui/core` by
107+
// objectui#4425 phase 2 and executed here by {@link toDomProps}.
108+
//
109+
// `SchemaRenderer` hands this renderer the authored node's own keys, the contents
110+
// of its `props` container, and any extra key the author wrote. The bare
111+
// `{...gridProps}` spread this replaces put all of it on the div as invalid HTML
112+
// attributes — measured on a canary node: `columns="4"`, `gap="4"`, `mdcolumns="2"`,
113+
// `smcolumns="2"`, `name="grid_node"`, `props="[object Object]"`,
114+
// `colorvariant="x"` and an unknown authored `zzcanary="leak"`, eight in all
115+
// (objectui#4787). Only `data-obj-*`/`style` were ever removed.
116+
//
117+
// Enumerating today's GridSchema keys instead would re-rot the moment the schema
118+
// grows one, and could never name the OPEN TAIL — `zzcanary` and the flattened
119+
// `props` container are author-supplied, so no finite list reaches them. The
120+
// whitelist keeps what is DECLARED DOM-safe (`id`, `className`, `role`, `tabIndex`,
121+
// … plus the open `data-*` / `aria-*` families, which is how `data-obj-id` and
122+
// `data-obj-type` still arrive) and drops everything else by construction.
123+
//
124+
// `style` is forwarded BY NAME rather than reopened in the shared whitelist (the
125+
// objectui#4435 route): it is this container's designer sizing channel, but the
126+
// shared set is deliberately element-agnostic and nothing element-specific belongs
127+
// in it. Grid's own keys (`columns`, `gap`, `smColumns`…) are CONSUMED off `schema`
128+
// above and must never be forwarded.
129+
const { style, ...hostProps } = props;
112130

113131
return (
114-
<div
115-
className={gridClass}
116-
{...gridProps}
117-
// Apply designer props
118-
data-obj-id={dataObjId}
119-
data-obj-type={dataObjType}
132+
<div
133+
{...toDomProps(hostProps)}
134+
className={gridClass}
120135
style={style}
121136
>
122137
{schema.children && renderChildren(schema.children)}

0 commit comments

Comments
 (0)