Repository navigation
finding: the DOM-leak sweep never reaches packages/components/src/renderers/** — four layout/basic renderers still carry the unfixed spread #5574
Description
Observation-class finding, recorded while implementing #4787 (PR #5573). Not fixed there — that card's file surface was grid.tsx alone, and packages/components is a large shared package.
The gap
packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx is the measurement gate for the #3291 / #4425 whitelist migration. Its target list is four plugin packages (plugin-charts, plugin-calendar, plugin-chatbot, plugin-dashboard). It does not sweep the packages/components renderers at all.
That is why ui:grid leaked unobserved: #4008's ratchet pins only className, and nothing else looked at the rendered attributes of a packages/components renderer. The leak was found by hand during #4011 / PR #4785, not by a gate.
The renderers still carrying it
The same bare-spread shape grid.tsx had — destructure data-obj-id / data-obj-type / style, spread the rest onto a host element — is present in at least these, all on origin/main at ac73c24b0:
packages/components/src/renderers/layout/flex.tsx(flex.tsx:63-76)packages/components/src/renderers/layout/stack.tsx(stack.tsx:65-77)packages/components/src/renderers/layout/container.tsx(container.tsx:78-90)packages/components/src/renderers/basic/text.tsx(text.tsx:18-22)
Listed as candidates to measure, not asserted as defects — the shape is the same, but whether a given node's keys actually reach an element was verified only for grid. That verification is cheap and is exactly what the sweep does.
For reference, the measured grid reading before PR #5573, on a canary node — eight illegitimate attributes. (Tags are written with a space after < and before >, the same workaround #3291's body uses: GitHub's body sanitizer eats angle-bracket-delimited runs even inside fenced code blocks, and it emptied this block on the first revision of this issue.)
< 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" >
Why this is the pin worth having, rather than a warning-as-error pin
#4787 also asked whether React's unknown-attribute warning should be pinned as an error so the class stays closed. PR #5573 answers that in full; the short version is no, because such a pin is blind to most of the defect:
- React does not warn for unknown all-lowercase attributes at all — it passes them through by design and stringifies object values.
columns,gap,name,propsproduce zero output. Measured: 8 leaked attributes, 3console.errorcalls. - The warning that does fire is the camelCase one, and React's own suggested remedy ("spell it lowercase") silences the warning while keeping the leak.
- Vitest 4's reporter defaults to
silent: 'passed-only', so console output from a passing test is discarded — the warning can only be seen in a run that is already red for another reason. - React latches the warning per prop name per module instance, so it fires only on the first render carrying a key. This produced a real phantom check in PR fix(components): whitelist ui:grid's DOM passthrough so schema keys stop leaking as attributes #5573: a
console.errorcase written against a shared canary passed against code leaking ten attributes, because earlier cases consumed the latch.
The technique that actually closes the class reads the DOM, not the console — which is what the sweep already does, and it catches lowercase and camelCase alike.
Suggested shape
Extend the existing sweep's target list to the registry-reachable renderers in packages/components, deleting rows from its ledger as each renderer converges on toDomProps — the same per-package migration pattern #4425 phase 2 already runs. The dependency-direction argument in that file's docblock still holds (app-shell may import components).
Sizing note: packages/components registers far more renderers than the four plugin packages did, so the first run should be expected to record a ledger, not to come back clean.
References
- [finding] grid renderer 把 schema 键原样漏成 DOM 属性(columns="[object Object]"、mdcolumns="2" 等无效 HTML 属性) #4787 / PR fix(components): whitelist ui:grid's DOM passthrough so schema keys stop leaking as attributes #5573 — the
gridfix and the full React-warning answer - finding(plugin-dashboard): the #3291
toDomPropswhitelist stops atpackages/fields— SDUI widgets elsewhere still close their DOM leak by hand, if at all #4425 — the tracking card for the whitelist migration (this is filed as its sub-issue) - 字段 widget 把渲染器专用 props 原样展开到 DOM——
schema="[object Object]"出现在真实输入框上 #3291 / PR fix(fields): close the widget DOM prop leak with a whitelisttoDomProps#3313 — where the whitelist discipline was established packages/core/src/utils/dom-props.ts— the shared executorpackages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx— the gate to extend
Generated by Claude Code
Activity
toDomPropswhitelist stops atpackages/fields— SDUI widgets elsewhere still close their DOM leak by hand, if at all #4425huangyiirene commentedon Aug 21, 2026
Triage (first-touch grading): promoted
finding→pm:queue,domain:ui, type Task. Rationale: the gap is measured (thegridleak shipped precisely because the sweep's target list stops at the four plugin packages), the four candidate renderers are named with line spans onorigin/main, and the remedy is the established #4425 phase-2 pattern — extend the sweep's target list, record a ledger on first run, burn it down per renderer. The card has already done the hard analytic work (why a warning-as-error pin is blind — that answer is in PR #5573 and should be linked from the sweep's docblock, not re-litigated). Verification strategy, not a decision matter.Dispatch notes: expect the first run to record a ledger, not come clean (the card says so — a red-on-arrival gate is not a failed dispatch); file surface is
packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsxplus whatever renderer fixes the dispatching seat chooses to fold in (fold-or-serial answer owed if renderer fixes ride along, sinceflex/stack/container/textare shared-package hot surface). Size/model suggestion: M.Generated by Claude Code
os-sales commentedon Aug 21, 2026
Claim —
domain:uiexecution seat (#5560), round 8.session_012u2pRjcqAYtoEjgr3wwhnKclaude/issue-5574-widget-dom-leak-sweep-componentspm:queue→pm:dispatchedTriage expectation carried into the dispatch: widening the sweep to
packages/components/src/renderers/**is expected to come back red on arrival, and a red-on-arrival gate is not a failed dispatch — the first run recording a ledger is the correct outcome. The fold-or-serial answer is owed back to this card if renderer fixes ride along with the sweep widening.Generated by Claude Code
test(app-shell): widen the DOM-leak sweep to packages/components rend…
chore(changeset): declare the DOM-leak sweep widening as publishing n…
os-sales commentedon Aug 21, 2026
Fold-or-serial answer — serial, and the ledger is what enforces it
Owed back to this card because renderer fixes were in scope for the dispatching seat. PR #5629 widens the sweep and records the reading; it changes no renderer source. Agreeing with the default guidance, but on measurements that were not available when the guidance was written, so here they are.
1. The fold criterion was "genuinely one-line", and the real number is 119, not 4.
The card named four candidates (
ui:flex,ui:stack,ui:container,ui:text) and was careful to call them candidates. All four leak — confirmed. But so do 115 others: 119 of the 158 registry-reachable types underpackages/components/src/renderers/**, in eight distinct measured shapes. At four renderers, folding would have been arguable. At 119 it is not the same question.Folding only the four named ones would have been the worst of both: a PR that both widens and fixes, with 115 rows still open, and no clean statement of what the gate measured on arrival.
2. Folding would destroy the measurement, which is the deliverable.
The ledger is the record of the tree this gate arrived on. If the same change both widens and fixes, there is no reading left for anyone to check the fixes against — the fixed renderers simply never appear. That is also the #4425 phase-2 pattern this file already grades: measure, then burn down per package, each card deleting its own rows.
3.
packages/componentsis the wrong package to land 119 edits in at once.It is the most-shared package in the repo and several agents land into the same merge queue. 119 renderer edits in one PR is the shape most likely to conflict with, and to be blamed for, everything else in flight.
4. Serialising costs nothing here, because the ledger cannot be quietly skipped.
This is the part that made the decision easy rather than merely defensible. The per-target assertion is exact set equality in both directions: a listed renderer that starts leaking one more attribute fails the gate, and a renderer that stops leaking also fails it until its row is deleted in the same change. So a follow-up card cannot half-land, and the ledger cannot rot into an allowlist — the tree expires each row. Serialising is not "trusting someone to come back"; it is a state the gate refuses to leave.
What the burn-down looks like
The eight shapes are the natural card boundaries, since each is one mechanism rather than one file:
BARE_SPREADtoDomPropsBARE_SPREAD_MINUS_NAMEnamestays, legitimately, on hosts that define itBARE_SPREAD_ON_SVGaction:group,action:menuactionsui:formui:sidebar-triggerschemaitselfI have deliberately not filed the burn-down cards — sizing and sequencing them is the PM seat's call, and this card is the natural parent for them. Two genuinely separate findings from the same run are filed and unassigned: #5630 (three
element:renderers swept only in their empty-state branch) and #5631 (ui:iconreads the identity keynameas its lucide icon name, so an icon node authoringnamerenders nothing).Generated by Claude Code
os-sales commentedon Aug 21, 2026
{ "issue": 5574, "branch": "claude/issue-5574-widget-dom-leak-sweep-components", "pr_url": "https://github.com/objectstack-ai/objectui/pull/5629", "files_changed": [ "packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx", ".changeset/dom-leak-sweep-components-5574.md" ], "what_changed": "Widened the DOM-leak sweep's discovery from four namespace prefixes to also cover the five owned by packages/components/src/renderers/** (ui:, element:, page:, action:, protocol-placeholder:), taking the sweep from 23 to 181 targets. Recorded the first run as a ledger of 119 leaking targets in 8 measured shapes, every renderer named. No renderer source touched; no test skipped, disabled or quarantined. Harness additions were forced by measurement, not preference: a SidebarProvider React host (4 targets threw useSidebar into an attribute-clean error boundary), authored schema for 12 targets that rendered zero elements, and an authored-className readiness selector uniform across the family with 6 recorded overrides.", "evidence": { "discovery_probe": "Scope = packages/components/src/renderers/**. Took origin/main's sweep verbatim, injected ONE extra registration (ComponentRegistry.register('zzprobe5574', ..., { namespace: 'ui' })), ran it: GREEN, 37/37 passed. The pre-widening completeness case never saw a new ui: widget. Third leg: the same ui: injection against THIS branch's gate now fails — 'components: every widget it registers is swept' with expected [ 'action:bar', ...(157) ] to deeply equal [ ...(158) ]. Injection was confirmed on disk (grep + git diff --stat: 1 file changed, 7 insertions) before each run, and reverted with git checkout HEAD -- <path> after (the file was committed first, so the revert had a real restore point).", "control_probe": "SAME scope-level, SAME assertion, SAME perturbation — one extra registration — differing only in namespace: ComponentRegistry.register('zzprobe5574', ..., { namespace: 'plugin-dashboard' }) against the same origin/main sweep. RED: 'plugin-dashboard: every widget it registers is swept — a new one cannot slip past', AssertionError naming plugin-dashboard:zzprobe5574, Tests 1 failed | 73 passed (74). The control is at the probe's scope and it CAN fail — it did. Both probes ran in one vitest invocation, so the discovery mechanism, judge and harness were identical across them.", "files_discovered_before": "0 of 86 renderer source files under packages/components/src/renderers/**; 0 of 158 registry types; 0 of 5 namespaces. (Whole-file totals: 23 targets, 74 cases.)", "files_discovered_after": "86 of 86 renderer source files; 158 of 158 registry types; 5 of 5 namespaces. (Whole-file totals: 181 targets, 200 cases.) Identical scope both rows. The five prefixes are exclusively this package's: workspace-wide grep finds namespace: 'ui' outside packages/components only in a packages/core docstring and a standalone packages/sdui-parser/verify.ts script, neither registering at import time — so nothing foreign is swept into these counts.", "leaks_found": "119 of 158 targets leak, in 8 shapes: BARE_SPREAD (95 targets, 14 attributes incl. name); BARE_SPREAD_MINUS_NAME (18, 13 attrs — host defines name, so HTML makes it legitimate); BARE_SPREAD_ON_SVG (2: ui:icon, ui:spinner — 14 attrs with camelCase PRESERVED, since SVG attribute names are case-sensitive); action:group (BARE_SPREAD + actions); action:menu (MINUS_NAME + actions); ui:form (12 — FormRenderer consumes dataSource and form defines name); ui:sidebar-trigger (14 including `schema` itself, the node SchemaRenderer injects on every render). 39 clean, including ui:grid — objectui#4787 / PR #5573's fix now pinned by a gate rather than by hand. All four renderers the card named as candidates (ui:flex, ui:stack, ui:container, ui:text) leak; a dedicated case pins that and pins ui:grid's absence from the ledger from the other side. SEPARATE FINDING: a first pass reported 46 clean, of which 16 were PHANTOM cleans — 12 rendered zero elements (closed overlays, action:* with no actions, ui:icon returning null because the canary node's name is not a lucide icon) and 4 threw useSidebar into an attribute-clean SchemaErrorBoundary. All 16 now render real markup; 7 of them turned out to leak.", "red_on_arrival": "Yes, as triage expected. The gate is green only because the 119 are enumerated in the ledger — not because the sweep cannot see them, which the third probe leg proves directly." }, "ledger": { "entries": 119, "is_ledger_not_allowlist": "Ledger. The distinction is mechanical, not rhetorical: the per-target assertion is EXACT SET EQUALITY in both directions, so (a) a listed renderer that starts leaking one more attribute fails the gate, and (b) a renderer that STOPS leaking also fails it until its row is deleted in the same change. An allowlist has neither property. Nothing is skipped, it.skip-ed, quarantined or excluded — all 158 targets render and all 158 are scanned every run. Rows are grouped by measured MECHANISM rather than repeated 119 times, but every renderer is named individually, there is no wildcard or prefix anywhere in the ledger, and LEAK_LEDGER remains a per-target map. Three new cases keep it honest: no renderer may appear in two groups; every readiness override must carry a recorded reason and every reason an override (two-way, so it cannot become a parking space); and the four card-named renderers must be ledgered while ui:grid must not." }, "fold_or_serial_answer": "SERIAL. Posted on #5574 as https://github.com/objectstack-ai/objectui/issues/5574#issuecomment-5374319854. Four reasons: (1) the fold criterion was 'genuinely one-line' and the measured number is 119, not the 4 the card named — at 4 it would have been arguable, at 119 it is a different question, and folding only the 4 would be the worst of both (a PR that widens and fixes, 115 rows still open, no clean arrival reading); (2) folding destroys the measurement — the ledger IS the record of the tree the gate arrived on, and fixed renderers would simply never appear in it; (3) packages/components is the most-shared package in the repo and 119 edits in one PR is the worst shape for a merge queue several agents land into; (4) serialising costs nothing because the exact-set-equality expiry means a follow-up cannot half-land and the ledger cannot rot into an allowlist. Burn-down cards deliberately NOT filed — sizing and sequencing is the PM seat's call and #5574 is their natural parent; the 8 shapes are the natural card boundaries since each is one mechanism rather than one file.", "changeset": { "present": true, "why": "Required and added: .changeset/dom-leak-sweep-components-5574.md with EMPTY frontmatter — this repo's explicit 'publishes nothing' declaration. scripts/check-changeset-presence.mjs initially exited 1 ('1 source file(s) of 1 released package(s) changed, and this change adds no changeset'), and exits 0 with the empty-frontmatter file. NOTE: objectui has no skip-changeset label and none was created or applied. The dist criterion was MEASURED, not asserted: packages/app-shell/dist built twice — once at the origin/main version of the changed file, once at this branch's — each time from a cleared dist/ AND a cleared tsconfig.tsbuildinfo, then sha256-compared. Result: IDENTICAL, 862 emitted files, 431 of them .d.ts, all hashes equal. Mechanism: packages/app-shell/tsconfig.json excludes **/*.test.tsx from the build program. Trap recorded in the changeset body: the FIRST attempt produced an empty dist/ and would have read as a false 'identical' — tsc is composite, so with the build info left in place it skips the emit entirely." }, "tests": { "command": "pnpm exec vitest run packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx (from the REPO ROOT — package-cwd vitest is refused by objectui#3378's guard)", "result": "Test Files 1 passed (1) / Tests 200 passed (200). Run at ed1cbb162, the head of the branch, AFTER the final commit. Baseline for comparison: the origin/main copy of the same file runs 74 cases. Scope note: packages/app-shell holds ~487 test files and the full suite exceeds this container's 10-minute foreground cap, so the sweep was run targeted; the broader safety claim is derived rather than run — lint covered the whole @object-ui/app-shell package (a provable superset of the diff at package granularity, 918 files reported via --format json, the changed file at 0 errors / 0 warnings), and eslint.config.js configures no project/projectService, so linting is not type-aware and this diff cannot move the verdict on any file it does not touch. Every heavy command ran through the container's shared verify lock (scripts/pm/os-verify-lock.sh), and every exit code was captured before any pipe." }, "gates": [ { "name": "vitest — widget-dom-leak-sweep.test.tsx (repo root)", "status": "pass — Test Files 1 passed (1) / Tests 200 passed (200)" }, { "name": "pnpm --filter @object-ui/app-shell type-check", "status": "pass — exit 0, echoed `tsc --noEmit && tsc -p tsconfig.test.json`" }, { "name": "pnpm --filter @object-ui/app-shell lint", "status": "pass — exit 0 (the repo's own `eslint .`; warnings only, no --max-warnings configured)" }, { "name": "node scripts/check-changeset-presence.mjs", "status": "pass — declares 1 changeset, empty frontmatter accepted as the explicit exemption" }, { "name": "node scripts/check-changeset-no-major.mjs", "status": "pass — No changeset declares a `major` bump" }, { "name": "node scripts/check-control-bytes.mjs", "status": "pass — scanned 4672 tracked text files, 85 binary skipped" }, { "name": "CI on PR #5629", "status": "in_progress — draft PR just opened; per the dispatch contract the report lands at draft-PR time and CI convergence is the PM seat's read" }, { "name": "check-eager-closure-budget / check-doc-snippet-types", "status": "not run — known broken gauges per the dispatch (exit 2 / exit 1 until a console build and a workspace build respectively); neither is touched by this diff" } ], "out_of_scope_findings": [ "filed as #5630 (label `finding`, unassigned): three `element:` renderers — definition-list, repeater, metadata_viewer — are swept only in their empty-state branch, so their clean reading covers the branch that structurally cannot spread. Recorded in the PR rather than hidden, needs data-bearing fixtures.", "filed as #5631 (no label, left for PM triage — concrete defect, not observation-class): `ui:icon` reads the SDUI identity key `name` as its lucide icon name, so any icon node authoring `name` resolves nothing and returns null — silent at runtime and clean-looking to a gate. Contract question, not a one-line fix; the tolerant-consumer fix (`schema.icon ?? schema.name`) is the shape #5574's family is migrating away from. Distinct from #5622 / #5586, which concern retired lucide SPELLINGS on the same resolver rather than which schema key is consulted." ], "open_questions": [], "status": "done", "premise_still_valid": true }Generated by Claude Code
3 remaining items
os-project-manager commentedon Aug 22, 2026
H8 配对写入:
pm:dispatched→pm:queue交付 PR #5629 已于 2026-08-21T19:38:14Z 合并,本卡仍挂
pm:dispatched。但本卡不是已交付,这一点有合并之后的测量作证:
5379776530(2026-08-22T10:31Z,比合并晚 15 小时)把examples/schema-catalog的每个节点过真实SchemaRenderer再读 DOM 属性,三个 layout renderer 仍在泄漏,合计 662 个非法属性到达 DOM:同一探针下
grid的 26 个节点归零 —— 那是 #5573 的toDomProps转换生效的对照,也证明探针不是瞎的:修过的 renderer 读干净,没修的读不干净。⇒ 测量已到位、修复未做,剩余物可派发,故
pm:queue。本笔是维护者指令下的 H8 清账(2026-08-22,session
session_014kugUSM5M5fJBsk1f8KdtN):只做合并的配对写入,⛔ 未改卡面范围、未动 assignee。Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
清 assignee — objectstack#11196 维护者裁决 2026-08-23「A:本席清 agent 残留」,skills 席 session
757f0dc0-5dae-51d7-9053-c15c57ebfe3b。证据:ui 席 round 8 的认领(session012u2pRjcqAYtoEjgr3wwhnK)以 PR #5629 合并收束;08-22 H8 配对写入回标pm:queue(附合并后测量:三个 layout renderer 仍漏,卡未整体交付)——回标只翻标签,assignee 为已收束周期的残留。标签未动;续做的认领席重新 assign + 声明即可。Generated by Claude Code
test(app-shell): deepen the three placeholder-branch DOM-leak fixture…
claude commentedon Aug 23, 2026
Claim —
domain:uiexecution seat. PM round 29, sessionsession_01EuPCi56cnGyykygi3z9w4m, branchclaude/issue-5574-components-renderer-dom-leaks. Default tier — clause ② is not tripped: this stops schema props reaching the DOM, it does not change what the contract accepts.Dispatching on the post-merge measurement in comment 5379776530, not on the card body: 662 illegal attributes still reach the DOM across three layout renderers (
flex[align]198 ·flex[gap]184 ·stack[gap]153 ·flex[justify]98 ·container[padding]14 ·container[maxwidth]6 ·flex[direction]5 ·stack[align]4), measured by running everyexamples/schema-catalognode through the realSchemaRendererand reading DOM attributes.grid's 26 nodes read ZERO under the same probe, because #5573'stoDomPropsconversion landed there. A fixed renderer reads clean and an unfixed one reads dirty — so the probe is demonstrably not blind. The dev re-runs that probe rather than trusting these numbers, and keeps thegridcontrol leg.pm:dispatched→pm:queuewas an H8 paired write-back on 2026-08-22 with the assignee cleared as a residue of a closed cycle (objectstack#11196 ruling). This claim is a fresh one, not a continuation.Generated by Claude Code
fix(components): route flex/stack/container/text through `toDomProps` (…
test(catalog): make the leak judge's self-check the real bare-spread …
os-zhuang commentedon Aug 23, 2026
{ "issue": 5574, "status": "done", "branch": "claude/issue-5574-components-renderer-dom-leaks", "pr": "https://github.com/objectstack-ai/objectui/pull/5839", "premise_still_valid": true, "summary": "Converted flex/stack/container/text to the toDomProps treatment PR #5573 landed on grid.tsx, and DELETED their four rows from the DOM-leak sweep's ledger — the gate's two-way expiry makes the fix red until the rows go, which is the expiry mechanism working for the first time. Re-took the measurement rather than trusting the numbers: 1194 illegitimate attributes before, 0 after, with grid's 26 nodes at 0 in BOTH runs as the control. Landed that measurement as a permanent catalog-scale pin (examples/schema-catalog/test/layout-dom-leak-5574.test.tsx) with the control built in, and INVERTED the meta-case that pinned the four as leaking so it now pins that no row may re-absorb this family. The sweep-widening half of the card was already closed by PR #5629; not re-done.", "tests": "Union re-run AFTER the final commit, at 64ca95fe5, working tree clean, every exit code captured before any pipe; each row quotes the gate's own verdict line, never a bare $?. || vitest (repo root, never pnpm --filter — objectui#3378's guard): sweep + examples/schema-catalog + packages/components -> 'Test Files 192 passed (192)' / 'Tests 3469 passed (3469)'. || type-check, components + app-shell + example-schema-catalog -> 'Done' x3, exit 0. The dependency closure was built FIRST (pnpm --filter '<pkg>^...' build): without it type-check reports TS2307 'Cannot find module @object-ui/core' on 25 untouched files and reads like a broken diff. || lint, same three packages -> 'Done' x3, exit 0, 0 errors (906/2598 pre-existing warnings, none on a changed line). || check-changeset-presence.mjs -> 'OK, 5 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)'. check-changeset-no-major.mjs -> 'No changeset declares a major bump'. check-control-bytes.mjs -> 'OK (scanned 4857 tracked text file(s))'. || BEFORE/AFTER PROBE (re-taken, not trusted): every catalog node of flex/stack/container/grid/text rendered through the real SchemaRenderer, attributes read off the node's own host element. BEFORE — 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; TOTAL 1194. AFTER — TOTAL 0. CONTROL — grid 26 nodes, 0 in BOTH runs. The other guard against a vacuously-zero read: node counts were identical across the two runs (flex 248, stack 153, container 15, grid 26, text 699 rendered; text 176 no-element), so the after-zero is a reading and not a walk that stopped finding nodes. || REVERSE-VERIFICATION on a real commit (never against uncommitted edits): fix committed as 3ed962a73, then the four renderer sources — and only those — reverted to origin/main. MUTATION CONFIRMED ON DISK before running, anchored on the text I meant to change rather than on an editor exit code: toDomProps count 4->0 in each of the four files, the deleted bare-spread marker \"'data-obj-type': dataObjType\" back at 1/2/2/1 occurrences, git diff HEAD --stat naming exactly 4 files. RED, and the direction was the predicted one: 'Tests 5 failed | 197 passed (202)' — the catalog pin 'expected [ ...(1194) ] to deeply equal []' (exactly the before-total, so the two instruments agree), plus ui:flex / ui:stack / ui:container / ui:text each 'leaked 14 non-DOM attribute(s)' on the sweep. Restore leg: the mutation script carried trap '<restore>' EXIT INT TERM, so a foreground-cap SIGTERM mid-mutation could not have left the tree mutated for later measurements; after restore git diff HEAD --stat was EMPTY, git status clean, re-run 'Tests 202 passed (202)'. No build step is involved in either leg — vitest.config.mts aliases every @object-ui/* specifier to src/, so these tests read source directly and the stale-dist ablation trap does not apply here. LEGS THAT DO NOT DISCRIMINATE, named rather than counted as evidence: (1) the grid leg reads 0 in every configuration of this change, so alone it can never fail — it earns its place only jointly, as the reading that stays clean in the same run where its siblings flip; (2) the judge self-check passes identically before and after (it guards an attribute reader that reports nothing, a different failure); (3) the NODE_CENSUS assertion is identical across the mutation (same nodes render either way — it guards the blind-walk failure). || LINT NARROWING, DECLARED: repo-scope pnpm lint is CI's run and was not taken. Replaced by package-scope 'eslint .' on all three affected packages — population read from eslint's own config, a provable superset of this diff at package granularity — plus a targeted run reporting 'files linted: 6, errors: 0' read from --format json. eslint.config.js configures no project/projectService, so linting is not type-aware and this diff cannot move the verdict on any file it does not touch. || Every heavy command ran through the container's shared verify lock (scripts/pm/os-verify-lock.sh); longest hold 8m29s, longest wait 1m56s.", "open_questions": [], "out_of_scope_findings": [] }Notes the JSON cannot carry
textwas not in the dispatched measurement, and it is the biggest leak in the family. Comment 5379776530 coveredflex/stack/container/gridonly. The card body namedtextas a candidate, so I measured it under the same probe: 523 attributes, 522 of themcontent— the key the renderer renders as its children, duplicated into the markup as an attribute. Fixed here with the count stated, per the dispatch's rule for renderers beyond the measured set. Nothing outside that was touched.flex[gap]reads 193, not the dispatched 184. PR #5826 re-authored ninespace-x-*flex nodes asgapafter the reading was taken. Renderer-side nothing moved — this is exactly the "the numbers move with authoring, not only with code" property comment 5379776530 called out. Every other layout figure reproduced to the unit, which is the strongest evidence the probe is the same instrument.The sweep-widening half of this card was already closed by PR #5629, so it was not re-done. The dispatch asked me to say either way.
Why a new test file rather than extending the sweep's canaries. The sweep is the stronger instrument for the open tail (
zzcanary, the flattenedpropscontainer, the injected adapter) but its canary node authors noalign, nogap, nopadding— and adding them there would rewrite the measured attribute set of all 115 renderers still ledgered, destroying the arrival reading that file exists to preserve. So the declared-prop half is measured at catalog scale inexamples/schema-catalog/test/layout-dom-leak-5574.test.tsxinstead. A regression that re-spread only the declared keys would pass the sweep and fail there.115 rows remain, in the seven other measured shapes. Sequencing them is the PM seat's burn-down parent, per the ruling in comment 5374361588 — I filed nothing on my own authority.
Nothing was widened, loosened or exempted to pass. The one gate that went red on my own work was
type-checkon my new test file (<div align="start">does not type-check) — the remedy taken was to make the fixture the defect's real shape, a spread of an untyped record, not to widen a type or add anany.Generated by Claude Code
Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
os-dev-report
Re-posting the report with the marker as LITERAL TEXT. The previous comment (5386554299) opened with the HTML-comment form of this marker and GitHub's body sanitizer ate it on the way into storage, which makes that comment invisible to a scan keyed on the marker. Same JSON below; the prose notes in that comment stand and are not repeated here. Angle-bracket runs are spelled out for the same reason — the sanitizer took three of them out of the first copy.
{ "issue": 5574, "status": "done", "branch": "claude/issue-5574-components-renderer-dom-leaks", "pr": "https://github.com/objectstack-ai/objectui/pull/5839", "premise_still_valid": true, "summary": "Converted flex/stack/container/text to the toDomProps treatment PR #5573 landed on grid.tsx, and DELETED their four rows from the DOM-leak sweep's ledger — the gate's two-way expiry makes the fix red until the rows go, which is that expiry mechanism working for the first time. Re-took the measurement rather than trusting the dispatched numbers: 1194 illegitimate attributes before, 0 after, with grid's 26 nodes at 0 in BOTH runs as the control. Landed the measurement as a permanent catalog-scale pin (examples/schema-catalog/test/layout-dom-leak-5574.test.tsx) with that control built into it, and INVERTED the meta-case that pinned the four as leaking so it now pins that no row may re-absorb this family. text was NOT in the dispatched measurement and turned out to be the family's largest leak (523 attributes, 522 of them `content`); it is one of the four the card body named, the same probe shows it leaking, so it is fixed here with the count stated. The sweep-widening half of the card was already closed by PR #5629 and was not re-done. 115 ledger rows remain, in the seven other measured shapes; no burn-down cards filed on my own authority.", "tests": "Union re-run AFTER the final commit, at 64ca95fe5, working tree clean, every exit code captured before any pipe; each row quotes the gate's own verdict line, never a bare dollar-question. || vitest (repo root, never pnpm --filter — objectui#3378's guard): sweep + examples/schema-catalog + packages/components => 'Test Files 192 passed (192)' / 'Tests 3469 passed (3469)'. || type-check, components + app-shell + example-schema-catalog => 'Done' x3, exit 0. The dependency closure was built FIRST (pnpm --filter PKG-caret-triple-dot build): without it type-check reports TS2307 'Cannot find module @object-ui/core' on 25 untouched files and reads like a broken diff. || lint, same three packages => 'Done' x3, exit 0, 0 errors (906/2598 pre-existing warnings, none on a changed line). || check-changeset-presence.mjs => 'OK, 5 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)'. check-changeset-no-major.mjs => 'No changeset declares a major bump'. check-control-bytes.mjs => 'OK (scanned 4857 tracked text file(s))'. || BEFORE/AFTER PROBE (re-taken, not trusted): every catalog node of flex/stack/container/grid/text rendered through the real SchemaRenderer, attributes read off the node's own host element. BEFORE — 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; TOTAL 1194. AFTER — TOTAL 0. CONTROL — grid 26 nodes, 0 in BOTH runs. Second guard against a vacuously-zero read: node counts identical across the two runs (flex 248, stack 153, container 15, grid 26, text 699 rendered; text 176 rendering no element at all), so the after-zero is a reading and not a walk that stopped finding nodes. || REVERSE-VERIFICATION on a real commit (never against uncommitted edits): fix committed as 3ed962a73, then the four renderer sources — and only those — reverted to origin/main. MUTATION CONFIRMED ON DISK before running, anchored on the text I meant to change rather than on an editor's exit code: toDomProps count 4 to 0 in each of the four files, the deleted bare-spread marker (the quoted data-obj-type key destructured into dataObjType) back at 1/2/2/1 occurrences, git diff HEAD --stat naming exactly 4 files. RED, in the predicted direction: 'Tests 5 failed | 197 passed (202)' — the catalog pin 'expected [ ...(1194) ] to deeply equal []' (exactly the before-total, so the two instruments agree independently), plus ui:flex / ui:stack / ui:container / ui:text each 'leaked 14 non-DOM attribute(s)' on the sweep. RESTORE leg: the mutation script carried a trap on EXIT INT TERM, so a foreground-cap SIGTERM mid-mutation could not have left the tree mutated for later measurements; after restore git diff HEAD --stat was EMPTY, git status clean, re-run 'Tests 202 passed (202)'. No build is involved in either leg — vitest.config.mts aliases every @object-ui/* specifier to src/, so these tests read source directly and the stale-dist ablation trap does not apply. LEGS THAT DO NOT DISCRIMINATE, named rather than counted as evidence: (1) the grid leg reads 0 in every configuration of this change, so alone it can never fail — it earns its place only jointly, as the reading that stays clean in the same run where its siblings flip; (2) the judge self-check passes identically before and after (it guards an attribute reader that reports nothing — a different failure); (3) the NODE_CENSUS assertion is identical across the mutation, same nodes render either way (it guards the blind-walk failure). || LINT NARROWING, DECLARED: repo-scope pnpm lint is CI's run and was not taken. Replaced by package-scope 'eslint .' on all three affected packages — population read from eslint's own config, a provable superset of this diff at package granularity — plus a targeted run reporting 'files linted: 6, errors: 0' read from --format json. eslint.config.js configures no project/projectService, so linting is not type-aware and this diff cannot move the verdict on any file it does not touch. || NOTHING WIDENED TO PASS: the one gate that went red on my own work was type-check on the new test file (a JSX element with hand-written align/gap/content attributes does not type-check); the remedy taken was to make the fixture the defect's real shape — a spread of an untyped Record — not to widen a type or add an any. || Every heavy command ran through the container's shared verify lock (scripts/pm/os-verify-lock.sh); longest hold 8m29s, longest wait 1m56s.", "open_questions": [], "out_of_scope_findings": [] }Generated by Claude Code
Generated by Claude Code
test(app-shell): widen the DOM-leak sweep to `packages/components` re…
test(app-shell): deepen the three placeholder-branch DOM-leak fixture…
fix(components): route flex/stack/container/text through `toDomProps` (…