diff --git a/.changeset/layout-renderers-dom-props-5574.md b/.changeset/layout-renderers-dom-props-5574.md new file mode 100644 index 000000000..b018f67b4 --- /dev/null +++ b/.changeset/layout-renderers-dom-props-5574.md @@ -0,0 +1,31 @@ +--- +'@object-ui/components': patch +--- + +**Behaviour change:** the `flex`, `stack`, `container` and `text` renderers no +longer forward their whole prop bag to the host element. They route it through +`toDomProps` — the same whitelist `grid` was converged on — so an authored +schema key becomes an HTML attribute only if the SDUI DOM contract declares it +one (objectui#5574). + +What this stops reaching the DOM: the renderer's own declared props, which were +consumed off `schema` to build the class list AND spread onto the element a +second time as attributes HTML does not define. Measured across +`examples/schema-catalog`, rendered through the real `SchemaRenderer`: 1194 +illegitimate attributes over 1141 nodes — `text[content]` 522, `flex[align]` +198, `flex[gap]` 193, `stack[gap]` 153, `flex[justify]` 98, `container[padding]` +14, `container[maxwidth]` 6, `flex[direction]` 5, `stack[align]` 4, +`text[value]` 1. The same probe reads 0 after, with `grid`'s 26 nodes at 0 both +times as the control. + +Nothing an author writes renders differently: every leaked key was already being +read off `schema` and applied as a class, so the markup loses attributes that +never had meaning and keeps the styling that did. `id`, `role`, `tabIndex`, +`className`, `style`, event handlers and the open `data-*` / `aria-*` families +are unchanged — including `data-obj-id` / `data-obj-type`, which now arrive +through the `data-*` family rather than by hand. + +Anything that read one of the leaked attributes off the DOM — a CSS attribute +selector such as `[gap="4"]`, or a test asserting `align` on a rendered `flex` — +must read the schema or the class instead. No `@object-ui` code did; this is +called out because the attributes were externally visible while they lasted. diff --git a/examples/schema-catalog/test/layout-dom-leak-5574.test.tsx b/examples/schema-catalog/test/layout-dom-leak-5574.test.tsx new file mode 100644 index 000000000..50468c5a1 --- /dev/null +++ b/examples/schema-catalog/test/layout-dom-leak-5574.test.tsx @@ -0,0 +1,226 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * No layout renderer puts an authored schema prop on the DOM (objectui#5574). + * + * ## What this measures that the sweep gate cannot + * + * `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx` is the gate + * for this class, and it is the stronger instrument for the OPEN TAIL: it plants + * canary keys no schema declares (`zzcanary`, a flattened `props` container, an + * injected adapter) on one node per registered type. What it does not plant are + * the renderer's OWN DECLARED PROPS — its canary node authors no `align`, no + * `gap`, no `padding`. Adding them there would rewrite the measured attribute set + * of all 115 renderers still ledgered, i.e. destroy the arrival reading that file + * exists to preserve. + * + * So the declared-prop half is measured HERE instead, at catalog scale, on real + * authored nodes. That is not a hypothetical distinction: every one of the 1194 + * attributes objectui#5574 measured was a DECLARED prop being consumed off + * `schema` AND forwarded to the element — `align`, `gap`, `justify`, `direction`, + * `padding`, `maxWidth`, `content`, `value`. A regression that re-spread only the + * declared keys would pass the sweep gate and fail here. + * + * ## The reading this pins + * + * Before the fix, rendering every catalog node of these five types through the + * real `SchemaRenderer` and reading the DOM: + * + * text[content] 522 container[padding] 14 + * flex[align] 198 container[maxwidth] 6 + * flex[gap] 193 flex[direction] 5 + * stack[gap] 153 stack[align] 4 + * flex[justify] 98 text[value] 1 + * TOTAL 1194 + * + * `grid` read ZERO in that same run, across 26 nodes, because objectui#4787 / + * PR #5573 had already converged it on `toDomProps`. That is what made the + * reading trustworthy rather than merely alarming — a fixed renderer read clean + * and its unfixed siblings did not, so the instrument was demonstrably not blind. + * + * ## Designing against the failure mode this test could have + * + * A zero here means nothing on its own: a renderer that renders NOTHING spreads + * nothing and reads clean, and so does a walk that found no nodes. Both are how + * a broken instrument reports a healthy tree. Two guards, and they are the + * reason this file is not just an `expect([]).toEqual([])`: + * + * 1. NODE COUNTS are asserted, per type, against the census below. If the + * catalog grows this fails and the number gets updated; if the walk breaks, + * or a renderer starts returning `null`, it fails and cannot read as clean. + * 2. The JUDGE is self-checked against a deliberately leaking element, so a + * zero can never come from an attribute reader that reports nothing. + * + * The 176 `text` nodes that render NO element are recorded rather than hidden: + * `text` returns a bare fragment when a node carries neither designer id nor + * className, and 176 catalog nodes take that path. They were never evidence of + * safety, which is the phantom-clean class objectui#5574's first pass found + * seven real leaks behind. + * + * Module-scope import of `@object-ui/components`, not `beforeAll` (AGENTS.md + * §测试纪律): registering the renderers is an unbounded module load and must not + * be billed to a bounded hook timeout. + */ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import '@object-ui/components'; +import { SchemaRenderer } from '@object-ui/react'; +import { allExamples } from '../src/index.js'; + +type Node = Record; + +/** + * The four renderers objectui#5574 converged, plus `grid` — kept in the same + * list, not as a courtesy but because a control that runs somewhere else is not + * a control. It reads clean in every configuration of this test, so on its own + * it discriminates nothing; what it does is carry PR #5573's fix forward under + * the same instrument that grades the other four. + */ +const MEASURED_TYPES = ['flex', 'stack', 'container', 'grid', 'text'] as const; + +/** + * Nodes of each type in the catalog today, and how many of them render no + * element at all. Asserted, so a zero leak reading is always accompanied by + * proof that something was actually rendered to read. + * + * These move when the CATALOG is authored, not when a renderer changes — + * objectui#5574's own measurement drifted by 9 `flex[gap]` between two readings + * purely because PR #5826 re-authored nine `space-x-*` nodes as `gap`. A diff + * that changes these numbers and nothing else is an example being added; update + * them. A diff that changes them while touching a renderer is the thing this + * guard is for. + */ +const NODE_CENSUS: Readonly> = { + flex: { rendered: 248, noElement: 0 }, + stack: { rendered: 153, noElement: 0 }, + container: { rendered: 15, noElement: 0 }, + grid: { rendered: 26, noElement: 0 }, + text: { rendered: 699, noElement: 176 }, +}; + +/** + * Attributes that are legitimately on a host element, so the judge ignores them. + * Deliberately NOT a list of what to catch — the set of keys an author may write + * is unbounded, which is the whole argument of + * `packages/core/src/utils/dom-props.ts`. Everything not named here is reported. + */ +function isLegitimate(name: string): boolean { + return ( + name === 'class' || + name === 'style' || + name === 'id' || + name === 'role' || + name === 'tabindex' || + name.startsWith('data-') || + name.startsWith('aria-') + ); +} + +/** Every attribute on `host` the SDUI DOM contract does not allow there. */ +function illegitimateAttributes(host: Element): string[] { + return Array.from(host.attributes) + .map((attribute) => attribute.name) + .filter((name) => !isLegitimate(name)) + .sort(); +} + +function collect(node: unknown, out: Node[] = []): Node[] { + if (Array.isArray(node)) { + for (const item of node) collect(item, out); + return out; + } + if (node && typeof node === 'object') { + const record = node as Node; + if ( + typeof record.type === 'string' && + (MEASURED_TYPES as readonly string[]).includes(record.type) + ) { + out.push(record); + } + for (const value of Object.values(record)) collect(value, out); + } + return out; +} + +describe('schema-catalog — no layout renderer leaks an authored prop to the DOM (#5574)', () => { + it('the judge reports a real leak — a zero below is a reading, not a blind spot', () => { + // Rendered directly rather than through the registry: this checks the + // ATTRIBUTE READER, and it must keep working even if every renderer in the + // repo is fixed. Without it, `illegitimateAttributes` could return `[]` + // unconditionally and every assertion in this file would still pass. + // + // The bag is spread rather than written as JSX attributes, which is not a + // typing dodge but the defect's own shape: `
` does not + // type-check, and a bare spread of an untyped record is exactly how these + // attributes got onto real elements without anyone hearing about it. + const bag: Record = { + className: 'c', + id: 'i', + 'data-obj-id': 'd', + align: 'start', + gap: 4, + content: 'x', + }; + const { container } = render(
); + expect(illegitimateAttributes(container.firstElementChild!)).toEqual([ + 'align', + 'content', + 'gap', + ]); + }); + + it('every catalog node of these five types renders, and none leaks', () => { + const leaks: string[] = []; + const rendered = new Map(); + const noElement = new Map(); + const bump = (map: Map, key: string) => + map.set(key, (map.get(key) ?? 0) + 1); + + for (const example of allExamples()) { + for (const node of collect(example.schema)) { + const type = String(node.type); + bump(rendered, type); + const { container, unmount } = render(); + const host = container.firstElementChild; + if (!host) { + bump(noElement, type); + unmount(); + continue; + } + for (const name of illegitimateAttributes(host)) { + leaks.push(`${example.id} :: ${type}[${name}]`); + } + unmount(); + } + } + + // Asserted BEFORE the leak reading, so a walk that rendered nothing fails + // as a broken instrument rather than passing as a clean tree. + const census = Object.fromEntries( + MEASURED_TYPES.map((type) => [ + type, + { rendered: rendered.get(type) ?? 0, noElement: noElement.get(type) ?? 0 }, + ]), + ); + expect( + census, + 'the catalog node census moved. If this diff only adds/removes examples, ' + + 'update NODE_CENSUS. If it touches a renderer, a node stopped rendering ' + + 'an element — and a renderer that renders nothing reads CLEAN below.', + ).toEqual(NODE_CENSUS); + + expect( + leaks, + 'an authored schema prop reached the DOM as an HTML attribute. These keys ' + + 'are CONSUMED off `schema` by the renderer; forwarding them as well is ' + + 'the objectui#3291 leak. Route the spread through `toDomProps` — never ' + + 'widen the whitelist and never add an exemption here.', + ).toEqual([]); + }, 120000); +}); diff --git a/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx b/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx index 1c4a7a7fe..534761ca7 100644 --- a/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx +++ b/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx @@ -37,9 +37,9 @@ * | plugin-calendar | 3 | 0 | 0 | * | plugin-chatbot | 3 | 0 | 0 | * | plugin-dashboard | 8 | 2 | 7 / 9 | - * | components | 158 | 119 | 12 .. 15 | + * | components | 158 | 115 | 12 .. 15 | * - * **121 of 181 targets leak.** The `components` row is objectui#5574 and is + * **117 of 181 targets leak.** The `components` row is objectui#5574 and is * covered in its own section below; the two `plugin-dashboard` rows are the * older tail. Both are in {@link LEAK_LEDGER}: * `plugin-dashboard:metric` and `plugin-dashboard:metric-card`, the open tail @@ -121,15 +121,24 @@ * have failed. These two differ in exactly one thing: which namespace the extra * widget went into. * - * ### The reading — 119 of 158, in eight shapes + * ### The reading — 119 of 158 ON ARRIVAL, in eight shapes; 115 today * * The card named four candidates (`flex`, `stack`, `container`, `text`) and was - * careful to call them candidates. All four leak. So do 115 others, and the + * careful to call them candidates. All four leaked. So did 115 others, and the * measurement is in {@link COMPONENTS_LEAK_GROUPS}, grouped by the MECHANISM * that produces each shape rather than one hand-written sentence per renderer. - * `ui:grid` reads clean, which is objectui#4787 / PR #5573's fix now pinned by + * `ui:grid` read clean, which is objectui#4787 / PR #5573's fix now pinned by * a gate instead of by hand. * + * The first four rows have since been DELETED rather than edited: `ui:flex`, + * `ui:stack`, `ui:container` and `ui:text` are converged on `toDomProps` and + * measure clean, so their rows had to go for the gate to pass — the two-way + * expiry below, working exactly once it had something to expire. 115 rows + * remain. What the arrival reading measured is preserved here in prose and in + * the burn-down note on {@link COMPONENTS_LEAK_GROUPS}; what the gate asserts + * is always current truth, which is the whole point of not writing dates into + * a ledger. + * * ### Four phantom cleans, which are the finding behind the finding * * A first pass over this family reported 46 clean targets. Sixteen of those @@ -857,14 +866,31 @@ interface LedgerEntry { /* ── objectui#5574: the `packages/components` reading, as a LEDGER ─────────── */ /** - * 119 of the 158 `packages/components` targets leak, and they do it in exactly - * EIGHT shapes. Writing 119 near-identical rows longhand would have buried that + * 115 of the 158 `packages/components` targets leak, and they do it in exactly + * EIGHT shapes (119 did on arrival; see the burn-down note below). Writing that + * many near-identical rows longhand would have buried the shapes * — so the rows are GROUPED BY MEASURED SHAPE, and every group names its * renderers one by one. Nothing here is a wildcard and nothing here is a * prefix: {@link LEAK_LEDGER} below is still a per-target map with exact set * equality, so the two-way expiry is unchanged. Fixing one renderer means * deleting one name from one list, and the gate stays red until that happens. * + * ## The burn-down, so far + * + * Rows leave this ledger by being DELETED in the change that fixes them, never + * by being edited into something looser. The record of what has left: + * + * - objectui#5574 (this card's second pass) — `ui:flex`, `ui:stack`, + * `ui:container`, `ui:text`, all four from {@link BARE_SPREAD}. Converged on + * `toDomProps` the way `grid.tsx` was by objectui#4787 / PR #5573. The + * catalog-scale reading that drove it: 248 `flex`, 153 `stack`, 15 + * `container` and 699 `text` nodes in `examples/schema-catalog` rendered + * through the real `SchemaRenderer` put 1194 illegitimate attributes on the + * DOM (`text[content]` 522, `flex[align]` 198, `flex[gap]` 193, `stack[gap]` + * 153, `flex[justify]` 98, `container[padding]` 14, `container[maxwidth]` 6, + * `flex[direction]` 5, `stack[align]` 4, `text[value]` 1); the same probe + * reads 0 after, with `grid`'s 26 nodes at 0 both times as the control. + * * ## This is a ledger, not an allowlist — the difference, stated once * * An allowlist says "do not look here". Every row below says "we looked, this @@ -874,7 +900,7 @@ interface LedgerEntry { * equals the recorded one; if it stops leaking, the gate ALSO fails until the * row goes. An allowlist has neither property. Nothing below is skipped, * `it.skip`-ed, quarantined or excluded from the sweep — all 158 targets render - * and all 158 are scanned on every run. + * and all 158 are scanned on every run, the 43 clean ones included. */ /** @@ -936,8 +962,8 @@ const COMPONENTS_LEAK_GROUPS: readonly LedgerGroup[] = [ 'action:bar', 'ui:a', 'ui:abbr', 'ui:accordion', 'ui:address', 'ui:alert', 'ui:app', 'ui:article', 'ui:aside', 'ui:aspect-ratio', 'ui:avatar', 'ui:b', 'ui:badge', 'ui:blockquote', 'ui:br', 'ui:breadcrumb', 'ui:button-group', 'ui:card', 'ui:carousel', - 'ui:cite', 'ui:collapsible', 'ui:command', 'ui:container', 'ui:dd', 'ui:del', 'ui:div', - 'ui:dl', 'ui:dt', 'ui:em', 'ui:empty', 'ui:figcaption', 'ui:figure', 'ui:flex', + 'ui:cite', 'ui:collapsible', 'ui:command', 'ui:dd', 'ui:del', 'ui:div', + 'ui:dl', 'ui:dt', 'ui:em', 'ui:empty', 'ui:figcaption', 'ui:figure', 'ui:footer', 'ui:h1', 'ui:h2', 'ui:h3', 'ui:h4', 'ui:h5', 'ui:h6', 'ui:header', 'ui:home', 'ui:hr', 'ui:html', 'ui:i', 'ui:image', 'ui:img', 'ui:ins', 'ui:kbd', 'ui:label', 'ui:li', 'ui:list', 'ui:loading', 'ui:main', 'ui:mark', 'ui:menubar', @@ -946,7 +972,7 @@ const COMPONENTS_LEAK_GROUPS: readonly LedgerGroup[] = [ 'ui:separator', 'ui:sidebar', 'ui:sidebar-content', 'ui:sidebar-footer', 'ui:sidebar-group', 'ui:sidebar-header', 'ui:sidebar-inset', 'ui:sidebar-menu', 'ui:sidebar-menu-item', 'ui:sidebar-provider', 'ui:skeleton', 'ui:small', 'ui:span', - 'ui:stack', 'ui:strong', 'ui:sub', 'ui:sup', 'ui:table', 'ui:tabs', 'ui:text', + 'ui:strong', 'ui:sub', 'ui:sup', 'ui:table', 'ui:tabs', 'ui:time', 'ui:toggle-group', 'ui:tree-view', 'ui:u', 'ui:ul', 'ui:utility', ], }, @@ -1046,7 +1072,7 @@ const LEAK_LEDGER: Readonly> = { issue: 'objectui#4425', }, - /* ── packages/components: 119 of 158 targets, in eight measured shapes ──── */ + /* ── packages/components: 115 of 158 targets, in eight measured shapes ──── */ ...Object.fromEntries( COMPONENTS_LEAK_GROUPS.flatMap((group) => group.targets.map((type) => [ @@ -1346,17 +1372,29 @@ describe('the sweep covers a real, non-empty target set (objectui#4425)', () => ).toEqual([]); }); - it('the four renderers objectui#5574 named are all ledgered, and `ui:grid` is NOT', () => { + it('the whole `toDomProps` layout family is CLEAN — no row may re-absorb it', () => { // The card listed `flex` / `stack` / `container` / `text` as CANDIDATES — - // same source shape as `grid`, unverified. This is the verification, and it - // is a two-sided pin: the three siblings and `text` carry the leak, while - // `ui:grid` — the one already converged by objectui#4787 / PR #5573 — is - // absent from the ledger because it is measured CLEAN. If a later change - // regresses `grid`, this case names it directly instead of leaving the - // reading to be re-derived. - const named = ['ui:flex', 'ui:stack', 'ui:container', 'ui:text']; - expect(named.filter((type) => !LEAK_LEDGER[type])).toEqual([]); - expect(LEAK_LEDGER['ui:grid']).toBeUndefined(); + // same source shape as `grid`, unverified. The first pass verified them: + // all four leaked, and they were ledgered. This is the state AFTER the fix, + // and the assertion had to be INVERTED to stay true, which is the two-way + // expiry doing its job — a row cannot outlive the defect it records. + // + // Keeping the case rather than deleting it is the point. The sweep case + // above already fails if one of these five regresses; what this one adds is + // that the regression cannot be made green by putting the row BACK. That is + // the one repair the ledger's shape would otherwise invite, and it converts + // a measurement into an allowlist entry. Fix the renderer; never re-ledger + // this family. + const converged = ['ui:flex', 'ui:stack', 'ui:container', 'ui:text', 'ui:grid']; + expect( + converged.filter((type) => LEAK_LEDGER[type]), + 'these renderers are converged on `toDomProps` (objectui#4787 / PR #5573 ' + + 'for `grid`, objectui#5574 for the other four) and measure clean. A row ' + + 'here means a regression was re-ledgered instead of fixed.', + ).toEqual([]); + // …and they are still SWEPT, so "no row" cannot mean "no longer looked at". + const sweptTypes = new Set(ALL_TARGETS.map((target) => target.type)); + expect(converged.filter((type) => !sweptTypes.has(type))).toEqual([]); }); it('the ledger is well formed — every row names a swept target, a reason and an issue', () => { diff --git a/packages/components/src/renderers/basic/text.tsx b/packages/components/src/renderers/basic/text.tsx index 772c3c5f0..94b4f6015 100644 --- a/packages/components/src/renderers/basic/text.tsx +++ b/packages/components/src/renderers/basic/text.tsx @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, toDomProps } from '@object-ui/core'; import type { TextSchema } from '@object-ui/types'; ComponentRegistry.register('text', @@ -14,23 +14,39 @@ ComponentRegistry.register('text', // Text is a special case as it might be rendered as a fragment or span depending on usage. // However, to support drag and drop in designer, it MUST be wrapped in an element if props are passed. - // Extract designer-related props - const { - 'data-obj-id': dataObjId, - 'data-obj-type': dataObjType, - style, - ...rest - } = props; + // DOM pass-through is a WHITELIST — objectui#3291's discipline, executed by + // {@link toDomProps}. Mechanism and full argument: `grid.tsx`'s docblock + // (objectui#4787 / PR #5573) and `packages/core/src/utils/dom-props.ts`. + // + // MEASURED (objectui#5574): every `text` node in `examples/schema-catalog` + // rendered through the real `SchemaRenderer` and read off the DOM — 523 + // illegitimate attributes, the largest single reading in the family, and all + // but one of them `content` (522; the odd one out is `value`). Those are the + // two keys this renderer RENDERS AS ITS CHILDREN, so every leaked attribute + // duplicated the visible text into the markup. + // + // The reading also has to be read against its own denominator: 699 `text` + // nodes rendered, and 176 of them produced NO ELEMENT at all — the fragment + // return below, taken when a node carries neither designer id nor className. + // A renderer that renders nothing spreads nothing and reads clean, so those + // 176 were never evidence of safety; they are the phantom-clean class the + // sweep's readiness selectors exist to keep honest. + // + // `style` is forwarded by name (the objectui#4435 route); `data-obj-*` arrive + // through the open `data-*` family {@link toDomProps} already forwards, which + // is why the wrap condition reads `data-obj-id` off `hostProps` rather than + // destructuring it out. + const { style, ...hostProps } = props; + const dataObjId = hostProps['data-obj-id']; + const className = schema.className || hostProps.className; // If we have designer props or className, we must wrap it to make it selectable and styleable - if (dataObjId || schema.className || rest.className) { + if (dataObjId || className) { return ( {schema.content || schema.value} diff --git a/packages/components/src/renderers/layout/container.tsx b/packages/components/src/renderers/layout/container.tsx index 0e5fefb4b..76bc137d0 100644 --- a/packages/components/src/renderers/layout/container.tsx +++ b/packages/components/src/renderers/layout/container.tsx @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, toDomProps } from '@object-ui/core'; import type { ContainerSchema } from '@object-ui/types'; import { renderChildren } from '../../lib/utils'; import { cn } from '../../lib/utils'; @@ -73,21 +73,30 @@ const ContainerRenderer = forwardRef {schema.children && renderChildren(schema.children)}
diff --git a/packages/components/src/renderers/layout/flex.tsx b/packages/components/src/renderers/layout/flex.tsx index 3c1272f81..82c320a56 100644 --- a/packages/components/src/renderers/layout/flex.tsx +++ b/packages/components/src/renderers/layout/flex.tsx @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, toDomProps } from '@object-ui/core'; import type { FlexSchema } from '@object-ui/types'; import { renderChildren } from '../../lib/utils'; import { cn } from '../../lib/utils'; @@ -58,21 +58,36 @@ ComponentRegistry.register('flex', className ); - // Extract designer-related props - const { - 'data-obj-id': dataObjId, - 'data-obj-type': dataObjType, - style, - ...flexProps - } = props; + // DOM pass-through is a WHITELIST, never a list of keys to strip — the + // objectui#3291 discipline, executed by {@link toDomProps} and already landed + // on the sibling `grid.tsx` (objectui#4787 / PR #5573), whose docblock carries + // the full argument. This replaces the bare `{...flexProps}` spread, which + // forwarded everything `SchemaRenderer` hands a renderer — the authored node's + // own keys, the flattened `props` container, the injected adapter and any + // extra key the author wrote — onto the div as invalid HTML attributes. + // + // MEASURED, not assumed (objectui#5574): every `flex` node in + // `examples/schema-catalog` rendered through the real `SchemaRenderer`, then + // read off the DOM — 494 illegitimate attributes across 248 nodes, led by + // `align` (198), `gap` (193), `justify` (98) and `direction` (5). Each of + // those four is a key CONSUMED off `schema` above and turned into a class; + // forwarding it as well is pure leakage. All four are all-lowercase, so React + // reports none of them — which is why the pin is the DOM-reading sweep in + // `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx` rather + // than a warning-as-error rule. `grid`'s 26 nodes read ZERO under the same + // probe, which is the control saying the reading is real. + // + // `style` is forwarded BY NAME rather than reopened in the shared whitelist + // (the objectui#4435 route): it is this container's designer sizing channel, + // and the shared set is deliberately element-agnostic. `data-obj-id` / + // `data-obj-type` need no special handling — they arrive through the open + // `data-*` family {@link toDomProps} already forwards. + const { style, ...hostProps } = props; return (
{schema.children && renderChildren(schema.children)} diff --git a/packages/components/src/renderers/layout/stack.tsx b/packages/components/src/renderers/layout/stack.tsx index 3d4befa1a..b43ef103c 100644 --- a/packages/components/src/renderers/layout/stack.tsx +++ b/packages/components/src/renderers/layout/stack.tsx @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, toDomProps } from '@object-ui/core'; import type { StackSchema } from '@object-ui/types'; import { renderChildren } from '../../lib/utils'; import { cn } from '../../lib/utils'; @@ -72,21 +72,29 @@ const StackRenderer = forwardRef {schema.children && renderChildren(schema.children)}