Context
Found while executing the #15327 takeover (Emmy's button.Base native-disabled projection, #15324). Grace's CHANGES_REQUESTED asked for rendered-paint parity evidence; building that witness surfaced something prior: the semantic layer itself has never worked in a mounted app. All three of the PR's component tests fail locally at the PR's own exact head (09a2c3d033) and after a dev rebase — and they fail on the pre-existing dev idiom, not on anything the rebase brought in.
Why every gate missed it: the PR's unit tests pass (vnode-realm, genuinely); GPT's review falsifiers probed the vnode plus a hand-built synthetic DOM control (not the Neo render pipeline); Grace's seat is offscreen/rAF-starved; and CI has no component shard — test/playwright/component/** runs in no workflow, so the PR's green checks never executed the mounted witnesses. Third signal-swallower documented today (after the docs-only unit-shard skip in #15362).
The Problem — probe chain, all reproducible
Environment: test/playwright/component/apps/empty-viewport/index.html via playwright.config.component.mjs, default DomApi renderer, branch = #15327's head rebased onto dev (also reproduced at the PR's own head).
- Create path:
createNeoInstance({ntype:'button', disabled:true, …}) → mounted element is <button type="button" class="neo-button neo-disabled icon-left"> — class arrives, type (template-declared) arrives, disabled attribute absent, el.disabled === false.
- Delta path:
setConfigs({disabled:false}) then ({disabled:true}) → logDeltaUpdates capture shows each toggle produces exactly ONE delta: {cls:{remove/add:['neo-disabled']}}. No attribute delta, ever. The worker-returned vnode carries attributes:{"type":"button"} only.
- Worker-side state:
Neo.worker.App.getConfigs({id, keys:['vdom','disabled']}) after disabled:true → config is true, vdom.cls contains neo-disabled, but the vdom root has no disabled key — the pristine template shape (tag/type/cn/id/cls/style). The vdomRoot.disabled = true mutation performed inside afterSetDisabled (via syncNativeDisabledState()) does not survive to the object the update pipeline reads.
- Unit realm (contrast): the PR's
test/playwright/unit/button/Base.spec.mjs passes 7/7 on the same branch — initVnode()/set() see both getVdomRoot().disabled === true and vnode.attributes.disabled === 'true'. vdom.Helper#createVnode's default branch maps unknown keys to attributes correctly (Helper.mjs:466-468), and main/render/DomApiRenderer.mjs:66 handles disabled via voidAttributes + toggleAttribute. The creator and renderer are innocent; the key is gone before they run.
So the loss sits in the live App-worker update path — between the config-hook's root mutation and the payload VdomLifecycle sends (getVdomUpdatePayload / the batch builder at src/mixin/VdomLifecycle.mjs:255-325). Note component.Base's style_ descriptor JSDoc already documents a "persistent state loop" re-applying root state for single-node components (wrapperStyle); whether the same machinery regenerates the root and drops ad-hoc keys is the first thing to falsify.
The Architectural Reality
- The mutation idiom Emmy used is byte-equivalent to
component.Base#changeVdomRootKey (root[key] = value; me.update()) — the framework's own pattern for height/html/etc. Those known keys route through style/innerHTML branches; disabled is the attribute default-branch case. If the live path drops ad-hoc root keys, changeVdomRootKey with any attribute-class key is equally affected — the blast radius is wider than buttons.
- The failing timing is specifically inside a config-hook cascade (
afterSetDisabled → super toggles cls (reactive set) → manual root mutation → update()), on a single-node component mounted inside a parent container — none of which the unit path exercises. That is the exact realm gap: the unit witness answers about a realm where the mutation survives.
The Fix
Two coupled deliverables, likely one PR once the mechanism is confirmed:
- Root-cause the drop in the live update path (first falsifier: dump the exact
updates[componentId] payload the batch builder emits for the button after the mutation — if disabled is present there, the wipe is later; if absent, walk getVdomUpdatePayload).
- Make the projection idiom survive it (or provide the sanctioned primitive for root-attribute projection), then re-land #15327's semantic layer on top, followed by the paint-parity witness Grace's RA1 specified (drafted during the takeover: shipped-vs-class-only computed styles, with a bare-attr instrument control so an "equal" verdict can't be a blind instrument — currently vacuous-green-prone until the attribute actually lands, which is why its
toBeDisabled() precondition matters).
Acceptance Criteria
Out of Scope
- The UA
button:disabled cascade styling question — that stays Grace's RA1 on PR #15327 (the paint-parity witness lands with the re-landed semantic layer).
- The CI component-shard gap as a standalone fix — named here as evidence; owns its own leaf if not folded in.
Related
PR #15327 / #15324 (the projection PR, held) · Grace's CHANGES_REQUESTED at 09a2c3d033 (the paint half) · GPT's earlier anchor-transition CR (repaired at head) · #15362 (today's sibling signal-swallower) · #13652 (the corpus: every witness answered about a realm the claim wasn't in).
Live latest-open sweep: checked latest 8 open at 2026-07-17T15:46Z (latest #15364, a Neo.ai.Config namespace collision — different substrate); no equivalent. A2A in-flight: #15327 takeover is my claimed lane (15:00/15:25 broadcasts, Clio de-conflicted 15:25); no competing claim on a vdom-pipeline bug.
Decision Record impact: none.
Origin Session ID: 2dcbf336-4338-4009-82f3-79f1b1d151f1
Retrieval Hint: "vdom root attribute mutation lost live update path button disabled toggleAttribute delta cls-only"
Context
Found while executing the #15327 takeover (Emmy's
button.Basenative-disabled projection, #15324). Grace's CHANGES_REQUESTED asked for rendered-paint parity evidence; building that witness surfaced something prior: the semantic layer itself has never worked in a mounted app. All three of the PR's component tests fail locally at the PR's own exact head (09a2c3d033) and after a dev rebase — and they fail on the pre-existingdevidiom, not on anything the rebase brought in.Why every gate missed it: the PR's unit tests pass (vnode-realm, genuinely); GPT's review falsifiers probed the vnode plus a hand-built synthetic DOM control (not the Neo render pipeline); Grace's seat is offscreen/rAF-starved; and CI has no component shard —
test/playwright/component/**runs in no workflow, so the PR's green checks never executed the mounted witnesses. Third signal-swallower documented today (after the docs-only unit-shard skip in #15362).The Problem — probe chain, all reproducible
Environment:
test/playwright/component/apps/empty-viewport/index.htmlviaplaywright.config.component.mjs, default DomApi renderer, branch = #15327's head rebased onto dev (also reproduced at the PR's own head).createNeoInstance({ntype:'button', disabled:true, …})→ mounted element is<button type="button" class="neo-button neo-disabled icon-left">— class arrives,type(template-declared) arrives,disabledattribute absent,el.disabled === false.setConfigs({disabled:false})then({disabled:true})→logDeltaUpdatescapture shows each toggle produces exactly ONE delta:{cls:{remove/add:['neo-disabled']}}. No attribute delta, ever. The worker-returned vnode carriesattributes:{"type":"button"}only.Neo.worker.App.getConfigs({id, keys:['vdom','disabled']})afterdisabled:true→ config istrue,vdom.clscontainsneo-disabled, but the vdom root has nodisabledkey — the pristine template shape (tag/type/cn/id/cls/style). ThevdomRoot.disabled = truemutation performed insideafterSetDisabled(viasyncNativeDisabledState()) does not survive to the object the update pipeline reads.test/playwright/unit/button/Base.spec.mjspasses 7/7 on the same branch —initVnode()/set()see bothgetVdomRoot().disabled === trueandvnode.attributes.disabled === 'true'.vdom.Helper#createVnode's default branch maps unknown keys to attributes correctly (Helper.mjs:466-468), andmain/render/DomApiRenderer.mjs:66handlesdisabledviavoidAttributes+toggleAttribute. The creator and renderer are innocent; the key is gone before they run.So the loss sits in the live App-worker update path — between the config-hook's root mutation and the payload
VdomLifecyclesends (getVdomUpdatePayload/ the batch builder atsrc/mixin/VdomLifecycle.mjs:255-325). Notecomponent.Base'sstyle_descriptor JSDoc already documents a "persistent state loop" re-applying root state for single-node components (wrapperStyle); whether the same machinery regenerates the root and drops ad-hoc keys is the first thing to falsify.The Architectural Reality
component.Base#changeVdomRootKey(root[key] = value; me.update()) — the framework's own pattern forheight/html/etc. Those known keys route through style/innerHTML branches;disabledis the attribute default-branch case. If the live path drops ad-hoc root keys,changeVdomRootKeywith any attribute-class key is equally affected — the blast radius is wider than buttons.afterSetDisabled→supertogglescls(reactive set) → manual root mutation →update()), on a single-node component mounted inside a parent container — none of which the unit path exercises. That is the exact realm gap: the unit witness answers about a realm where the mutation survives.The Fix
Two coupled deliverables, likely one PR once the mechanism is confirmed:
updates[componentId]payload the batch builder emits for the button after the mutation — ifdisabledis present there, the wipe is later; if absent, walkgetVdomUpdatePayload).toBeDisabled()precondition matters).Acceptance Criteria
toBeDisabled()green in a real browser).attributesdeltas, not cls-only).playwright.config.component.mjs— and the shard-gap (component tests in no CI workflow) is ticketed or fixed alongside.Out of Scope
button:disabledcascade styling question — that stays Grace's RA1 on PR #15327 (the paint-parity witness lands with the re-landed semantic layer).Related
PR #15327 / #15324 (the projection PR, held) · Grace's CHANGES_REQUESTED at
09a2c3d033(the paint half) · GPT's earlier anchor-transition CR (repaired at head) · #15362 (today's sibling signal-swallower) ·#13652(the corpus: every witness answered about a realm the claim wasn't in).Live latest-open sweep: checked latest 8 open at 2026-07-17T15:46Z (latest #15364, a Neo.ai.Config namespace collision — different substrate); no equivalent. A2A in-flight: #15327 takeover is my claimed lane (15:00/15:25 broadcasts, Clio de-conflicted 15:25); no competing claim on a vdom-pipeline bug.
Decision Record impact:
none.Origin Session ID:
2dcbf336-4338-4009-82f3-79f1b1d151f1Retrieval Hint:
"vdom root attribute mutation lost live update path button disabled toggleAttribute delta cls-only"