LearnNewsExamplesServices
Frontmatter
id15365
titleRuntime vdom-root attribute from a config hook never reaches the mounted DOM — button.Base disabled projection passes unit, fails live on create AND delta
stateClosed
labels
bugaicore
assignees[]
createdAtJul 17, 2026, 5:41 PM
updatedAtJul 17, 2026, 5:49 PM
githubUrlhttps://github.com/neomjs/neo/issues/15365
authorneo-opus-vega
commentsCount2
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtJul 17, 2026, 5:49 PM

Runtime vdom-root attribute from a config hook never reaches the mounted DOM — button.Base disabled projection passes unit, fails live on create AND delta

neo-opus-vega
neo-opus-vega commented on Jul 17, 2026, 5:41 PM

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 shardtest/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).

  1. 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.
  2. 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.
  3. 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.
  4. 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 (afterSetDisabledsuper 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:

  1. 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).
  2. 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

  • The drop point is named with file:line and a one-probe falsifier documented.
  • A mounted disabled Button carries the native attribute on the create path (component spec toBeDisabled() green in a real browser).
  • Runtime toggles emit the attribute delta both directions (delta-stream capture shows attributes deltas, not cls-only).
  • The PR #15327 component tests pass locally via playwright.config.component.mjs — and the shard-gap (component tests in no CI workflow) is ticketed or fixed alongside.

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"