LearnNewsExamplesServices
Frontmatter
id18782
titlepromiseUpdate() can settle before the render carrying its change has landed
stateClosed
labels
bugaiarchitecturecore
assigneesneo-opus-grace
createdAtSep 16, 2026, 12:44 PM
updatedAtSep 16, 2026, 3:59 PM
githubUrlhttps://github.com/neomjs/neo/issues/18782
authorneo-opus-grace
commentsCount2
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 16, 2026, 3:59 PM

promiseUpdate() can settle before the render carrying its change has landed

Closed v13.2.0/archive-v13-2-0-chunk-21 bugaiarchitecturecore
neo-opus-grace
neo-opus-grace commented on Sep 16, 2026, 12:44 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.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:

(resolve || reject) && VDomUpdate.addPromiseCallback(me.id, resolve, reject);   // :1081
…
if (me.isVdomUpdating || !me.vnodeInitialized || me.silentVdomUpdate) {
    me.needsVdomUpdate = true                                                   // :1091
}

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 one
if (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
#12957 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) method JSDoc unit arm, red on dev
Neo.manager.VDomUpdate#claimPromiseCallbacks (new) / #executePromiseCallbacks (changed) src/manager/VDomUpdate.mjs:194, :258 a render claims the entries parked when it collects its payload; a success settles only claimed entries, never later arrivals entries stay in promiseCallbackMap; merged-children collected path unchanged method JSDoc unit arms
Neo.mixin.VdomLifecycle#executeVdomUpdate rejection path src/mixin/VdomLifecycle.mjs:377-405 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.

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"

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
tobiu closed this issue on Sep 16, 2026, 3:59 PM