Measured while building grid cell editing (#18764). The plugin did the obvious thing — re-render the Row, await the render, then focus the editor it had just embedded:
Under CPU throttling the focus landed on a DOM that did not contain the editor yet: 11 of 12 double-clicks at 4× and 12 of 12 at 8× left focus on the View, and the full components tier reproduced it once unthrottled. A main-thread trace (wrapping Neo.main.DomAccess.focus and a MutationObserver on the grid) shows the order:
44 focus neo-grid-view-1 exists=true
55 focus neo-textfield-1__input exists=false ← the await had already resolved
81 editor inserted
The render that inserted the editor landed ~25ms after the promise for it resolved. Main is not at fault: Main.processQueue settles a DOM operation's reply only after applying it (src/Main.mjs:578-586).
#18764 no longer depends on the promise — the editor focuses on its own mounted event — so this ticket is the engine defect that lane found, not a blocker for it.
The Problem
VdomLifecycle#updateVdom parks the promise under the component id before it branches:
resolveVdomUpdate then settles every callback parked under that id:
VDomUpdate.executeCallbacks(me.id, data, mergedChildIds); // :880 → executePromiseCallbacks (VDomUpdate.mjs:244)
…
if (me.needsVdomUpdate) {
// any new promise callbacks will get picked up by the next update cycle
me.update() // :891-893
}
So a promiseUpdate() requested while the component's own flight is in the air settles with that flight — the one that has already collected its payload (executeVdomUpdate collects after a setTimeout(1) macrotask yield, :236-240) and therefore does not carry the caller's change. The comment at :892 states the opposite of what the code does.
The engine already owns the concept it is missing here. For children merged into a flight, VDomUpdate.markCollected sets a collected flag and executeCallbacks settles only the entries this flight collected:
// Only the entry this flight collected: a child that merged again in the air holds a newer oneif (item.children.get(childId)?.collected) { … } // VDomUpdate.mjs:209-214, :501-514
The owner's own promise callbacks have no equivalent, so they are settled wholesale.
The Architectural Reality
surface
today
src/mixin/VdomLifecycle.mjs:1081
parks {resolve, reject} under me.id before any branching — correct for rejection honesty (#12957), too early for timing
src/mixin/VdomLifecycle.mjs:236-240
the flight yields a macrotask, then collects payloads — the moment after which a new request cannot be in this flight
src/mixin/VdomLifecycle.mjs:880 / :891
settles every parked callback, then re-runs for needsVdomUpdate
src/manager/VDomUpdate.mjs:244-254
executePromiseCallbacks drains the whole list for an id
src/manager/VDomUpdate.mjs:209-214, :501-514
the collected discipline that already solves this for merged children
src/Main.mjs:578-586
main settles a DOM op's reply after applying it, so the gap is worker-side
fixed the reject side of the same registration, and recorded that executeCallbacks "fires ALL parked resolves for the id" — the observation, never the timing consequence
The Fix
Extend the collected discipline from merged children to the flight's own promise callbacks.
Claim at payload collection, as a flag. Entries stay in promiseCallbackMap and are marked claimed, exactly as collected marks merged children, so every rejection path — destroy included — still reaches all of them. When executeVdomUpdate has collected its payload, it claims the callbacks parked under its id; resolveVdomUpdate / the catch settle exactly the claimed set.
A request that arrives after the claim stays parked, and the needsVdomUpdate re-run — which already exists at :891 — carries it, so it settles with the flight that carries its change.
promiseUpdate's JSDoc states the guarantee it then has: the promise settles after the DOM carries the change requested before it.
Contract Ledger
Target Surface
Source of Authority
Proposed Behavior
Fallback
Docs
Evidence
Neo.mixin.VdomLifecycle#promiseUpdate
src/mixin/VdomLifecycle.mjs:853
settles after the render carrying the caller's change has landed
a request made before the in-flight payload was collected settles with that flight, as today; a component not mounted yet settles every parked promise when the render that mounts it lands (Residual-Owner #18802)
a failing flight rejects every promise parked for the components it covers, claimed or not — including a later arrival whose change it did not carry
#12957's honest rejection preserved for every entry; destroy rejects whatever is still parked
promiseUpdate JSDoc
unit arm
Acceptance Criteria
AC-1 — a promiseUpdate() requested after a flight has collected its payload does not settle with that flight; it settles after the flight that carries its change, with that change present in the resulting deltas or vnode (red on dev). Scope: a mounted component. The not-yet-mounted window — a mount settles every parked promise — is owned by #18802. (Scoped at review, per @neo-opus-ada's RA-3.)
AC-2 — CONTROL: a promiseUpdate() requested before payload collection still settles with the flight in the air.
AC-3 — a failing flight rejects every promise parked for the components it covers, claimed or not, so nothing strands. A request that arrived after the claim may be rejected with it, as on dev; the settle-timing guarantee is a success-path property. (Amended during implementation: the first cut rejected only claimed entries, which stranded the initiator’s own promise when a flight failed before it claimed.)
AC-3b — a component destroyed while its flight is in the air settles every promise parked under its id — the ones that flight claimed and the ones that arrived after the claim — with Neo.isDestroyed. None strands. (Proposed by @neo-opus-ada in the prescription check; it is the path a claim-by-move would break silently.)
AC-4 — merged-child callbacks keep settling exactly as they do now; the collected arms for children stay green.
AC-5 — no regression: the vdom, component and grid unit collections, the components tier, and e2e-engine.
AC-6 — promiseUpdate's JSDoc states the settle guarantee, and the stale comment at :892 is corrected or removed.
Out of Scope
The 5s wedge watchdog and the catch-drain from #12946 / #12956.
Merge and collision policy itself (mergeIntoParentUpdate, isParentUpdating): this changes when a flight settles promises, not which flight renders what.
grid.plugin.CellEditing, which no longer depends on the promise (#18764).
Avoided Traps
Claiming at flight start. A change made before payload collection is in the flight; settling it late would be a second defect in the other direction, and the setTimeout(1) yield exists precisely so that window is usable.
Making consumers await twice.await promiseUpdate(); await promiseUpdate() would paper over it and leave every existing caller wrong.
Fixing it in grid. The lane that found it has moved on; a Row-local workaround would leave every other consumer with a promise that lies.
Related
#12957 (the reject-side fix on the same registration, which recorded the wholesale-drain observation) · #12946 · PR #12956 · #18764 / PR #18769 (the lane that measured it) · defect-note broadcast 2026-09-16, MESSAGE:a179bc78-ad28-4537-89a5-3666fa3f855c
Live latest-open sweep: latest 20 open issues at 2026-09-16T10:43Z; no equivalent — the open test-defect tickets (#18770, #18772, #18780, #18781) are different surfaces. A2A claim sweep: latest 30 inbox messages, all read-states, same window; no claim on VDomUpdate or VdomLifecycle. Memory Core rationale sweep: query_raw_memories on the symptom nouns; hits are the 2026-01 functional-component id-sync sessions and the 2026-03 Row performance work, no decision on settle timing. Closed-issue sweep: promiseUpdate / executeCallbacks across all states surfaced #12957 and the dock-projection family, none of which owns this. Own-assignment sweep: 6 open, none on the vdom engine.
Retrieval Hint: "promiseUpdate settles with the flight already in the air; claim promise callbacks at payload collection the way merged children are marked collected"
tobiu referenced in commit f490721 - "fix(vdom): a flight settles the promises it claimed at payload collection (#18782) (#18787) on Sep 16, 2026, 3:59 PM
Context
Measured while building grid cell editing (#18764). The plugin did the obvious thing — re-render the Row, await the render, then focus the editor it had just embedded:
row.createVdom(true, false); await row.promiseUpdate(); editor.focus()Under CPU throttling the focus landed on a DOM that did not contain the editor yet: 11 of 12 double-clicks at 4× and 12 of 12 at 8× left focus on the View, and the full components tier reproduced it once unthrottled. A main-thread trace (wrapping
Neo.main.DomAccess.focusand aMutationObserveron the grid) shows the order:The render that inserted the editor landed ~25ms after the promise for it resolved. Main is not at fault:
Main.processQueuesettles a DOM operation's reply only after applying it (src/Main.mjs:578-586).#18764 no longer depends on the promise — the editor focuses on its own
mountedevent — so this ticket is the engine defect that lane found, not a blocker for it.The Problem
VdomLifecycle#updateVdomparks the promise under the component id before it branches:(resolve || reject) && VDomUpdate.addPromiseCallback(me.id, resolve, reject); // :1081 … if (me.isVdomUpdating || !me.vnodeInitialized || me.silentVdomUpdate) { me.needsVdomUpdate = true // :1091 }resolveVdomUpdatethen settles every callback parked under that id:VDomUpdate.executeCallbacks(me.id, data, mergedChildIds); // :880 → executePromiseCallbacks (VDomUpdate.mjs:244) … if (me.needsVdomUpdate) { // any new promise callbacks will get picked up by the next update cycle me.update() // :891-893 }So a
promiseUpdate()requested while the component's own flight is in the air settles with that flight — the one that has already collected its payload (executeVdomUpdatecollects after asetTimeout(1)macrotask yield,:236-240) and therefore does not carry the caller's change. The comment at:892states the opposite of what the code does.The engine already owns the concept it is missing here. For children merged into a flight,
VDomUpdate.markCollectedsets acollectedflag andexecuteCallbackssettles only the entries this flight collected:// Only the entry this flight collected: a child that merged again in the air holds a newer one if (item.children.get(childId)?.collected) { … } // VDomUpdate.mjs:209-214, :501-514The owner's own promise callbacks have no equivalent, so they are settled wholesale.
The Architectural Reality
src/mixin/VdomLifecycle.mjs:1081{resolve, reject}underme.idbefore any branching — correct for rejection honesty (#12957), too early for timingsrc/mixin/VdomLifecycle.mjs:236-240src/mixin/VdomLifecycle.mjs:880/:891needsVdomUpdatesrc/manager/VDomUpdate.mjs:244-254executePromiseCallbacksdrains the whole list for an idsrc/manager/VDomUpdate.mjs:209-214,:501-514collecteddiscipline that already solves this for merged childrensrc/Main.mjs:578-586executeCallbacks"fires ALL parked resolves for the id" — the observation, never the timing consequenceThe Fix
Extend the
collecteddiscipline from merged children to the flight's own promise callbacks.promiseCallbackMapand are marked claimed, exactly ascollectedmarks merged children, so every rejection path — destroy included — still reaches all of them. WhenexecuteVdomUpdatehas collected its payload, it claims the callbacks parked under its id;resolveVdomUpdate/ the catch settle exactly the claimed set.needsVdomUpdatere-run — which already exists at:891— carries it, so it settles with the flight that carries its change.promiseUpdate's JSDoc states the guarantee it then has: the promise settles after the DOM carries the change requested before it.Contract Ledger
Neo.mixin.VdomLifecycle#promiseUpdatesrc/mixin/VdomLifecycle.mjs:853devNeo.manager.VDomUpdate#claimPromiseCallbacks(new) /#executePromiseCallbacks(changed)src/manager/VDomUpdate.mjs:194,:258promiseCallbackMap; merged-childrencollectedpath unchangedNeo.mixin.VdomLifecycle#executeVdomUpdaterejection pathsrc/mixin/VdomLifecycle.mjs:377-405promiseUpdateJSDocAcceptance Criteria
promiseUpdate()requested after a flight has collected its payload does not settle with that flight; it settles after the flight that carries its change, with that change present in the resulting deltas or vnode (red ondev). Scope: a mounted component. The not-yet-mounted window — a mount settles every parked promise — is owned by #18802. (Scoped at review, per @neo-opus-ada's RA-3.)promiseUpdate()requested before payload collection still settles with the flight in the air.dev; the settle-timing guarantee is a success-path property. (Amended during implementation: the first cut rejected only claimed entries, which stranded the initiator’s own promise when a flight failed before it claimed.)Neo.isDestroyed. None strands. (Proposed by @neo-opus-ada in the prescription check; it is the path a claim-by-move would break silently.)collectedarms for children stay green.vdom,componentandgridunit collections, the components tier, ande2e-engine.promiseUpdate's JSDoc states the settle guarantee, and the stale comment at:892is corrected or removed.Out of Scope
mergeIntoParentUpdate,isParentUpdating): this changes when a flight settles promises, not which flight renders what.grid.plugin.CellEditing, which no longer depends on the promise (#18764).Avoided Traps
setTimeout(1)yield exists precisely so that window is usable.await promiseUpdate(); await promiseUpdate()would paper over it and leave every existing caller wrong.grid. The lane that found it has moved on; a Row-local workaround would leave every other consumer with a promise that lies.Related
#12957 (the reject-side fix on the same registration, which recorded the wholesale-drain observation) · #12946 · PR #12956 · #18764 / PR #18769 (the lane that measured it) · defect-note broadcast 2026-09-16,
MESSAGE:a179bc78-ad28-4537-89a5-3666fa3f855cLive latest-open sweep: latest 20 open issues at 2026-09-16T10:43Z; no equivalent — the open test-defect tickets (#18770, #18772, #18780, #18781) are different surfaces. A2A claim sweep: latest 30 inbox messages, all read-states, same window; no claim on
VDomUpdateorVdomLifecycle. Memory Core rationale sweep:query_raw_memorieson the symptom nouns; hits are the 2026-01 functional-component id-sync sessions and the 2026-03 Row performance work, no decision on settle timing. Closed-issue sweep:promiseUpdate/executeCallbacksacross all states surfaced #12957 and the dock-projection family, none of which owns this. Own-assignment sweep: 6 open, none on the vdom engine.Origin Session ID: a758f0e1-0dc1-4e4e-a975-4824a9319487
Retrieval Hint: "promiseUpdate settles with the flight already in the air; claim promise callbacks at payload collection the way merged children are marked collected"