LearnNewsExamplesServices
Frontmatter
id18143
titleDock projection: a rejected vdom update mid-swap strands two shells in the host
stateClosed
labels
bugaidashboard
assigneesneo-opus-grace
createdAtSep 2, 2026, 11:32 PM
updatedAtSep 3, 2026, 12:15 AM
githubUrlhttps://github.com/neomjs/neo/issues/18143
authorneo-opus-vega
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 3, 2026, 12:15 AM

Dock projection: a rejected vdom update mid-swap strands two shells in the host

neo-opus-vega
neo-opus-vega commented on Sep 2, 2026, 11:32 PM

Context

Observed live on 2026-09-02 in a downstream dock workspace app (engine checkout at dev@ec6856b804, Chrome, shared workers), read through the Neural Link while the broken window was still open. The user had pinned a right-edge pane to its rail, pinned it back, pinned it again and pinned it back again. The dock host then held TWO projected shells:

host child what it held visibility x
neo-container-21 (the outgoing shell) every real pane: two grids, the editor pane, an emptied aux tabs container whose header had no tab button left visible -1397 (one host width left)
neo-container-25 (the staged shell) three tab-node placeholders (neo-component-53/54/55) and a fresh rail with its reveal overlay hidden 0

App-worker console, one entry, immediately followed by an unhandled-rejection report from the app's logger:

vdom update failed <workspace id> util.VNode.getVnode: Component not found for id: neo-tab-header-button-10
    at VNode.getVnode          (src/util/VNode.mjs:212)
    at VNode.createMap         (src/util/VNode.mjs:25 … :41)
    at Workspace.syncVnodeTree (src/mixin/VdomLifecycle.mjs:925)
    at Workspace.afterSetVnode (src/mixin/VdomLifecycle.mjs:125)

neo-tab-header-button-10 was the tab button of the pane leaving for the rail. The host's scrollLeft was 1397 as well; that part is the preview-in-flow defect filed separately and is what made the user see an empty workspace instead of the hidden staged shell — but with the preview fixed the state above is still fatal: the shell in view is the hidden one.

The Problem

Reconciler.reconcileProjection (src/dashboard/dock/projection/Reconciler.mjs:341) is a four-phase transaction, each phase host.update(); await host.promiseUpdate():

  1. insert the next shell hidden at shellIndex + 1 (:426), commit;
  2. reconcileTabChrome — move pane/button pairs, retire leavers (state.bar.removeTabAt(index, true, true) destroys the button SILENTLY, :769-770), commit the bars whose membership changed, hide emptied source tabs, commit;
  3. moveRetainedTabChrome, swap shell visibility, commit;
  4. host.remove(oldShell, true, true) (:488), commit.

A rejected promiseUpdate() anywhere after phase 1 throws out of the method. Nothing catches it: not reconcileProjection, not Workspace#refreshDockWorkspace (src/dashboard/dock/Workspace.mjs, the await Reconciler.reconcileProjection(...) at :3525 sits outside the only try in that method), and onDockZoneDocumentChange (:3347-3361) puts the .catch(() => {}) on the PREVIOUS refresh only, so the failure surfaces as an unhandled rejection and the host keeps both shells: the outgoing one still visible and populated, the staged one hidden and half-built. Every later commit reconciles against host.items[shellIndex] again, so the state does not self-heal.

The rejection itself is a stale vnode reference: VNode.createMap follows {componentId} references through the live component registry and THROWS when a referenced component is gone (src/util/VNode.mjs:212). Here the workspace's OWN flight landed while the aux tab bar's stored vnode still listed the button that phase 2 had just destroyed silently. Which workspace-level write opened that flight is not established — MotionSignal.enter/leave (addCls/removeCls of neo-dashboard-dock-animating on the workspace, src/dashboard/dock/projection/MotionSignal.mjs) is the one workspace-level vdom write on the refresh path; the timing that lands it inside a later transaction's phase 2 (a slow FLIP played promise reaching the 2000 ms fail-safe, or two commits inside one motion window) is the open question. The observing workspace has no vdom writes of its own on the workspace node.

Two separate headless reproductions of the same pin cycles (three cycles each, Chrome and Chromium) did not hit the rejection; the user hit it at human pace. Treat the race as intermittent and the missing failure path as deterministic.

The Architectural Reality

  • The reconciler's promise (docking design record §2.6, reparent-never-recreate) is that panes survive a re-projection by identity. A half-run transaction breaks that promise in the worst way: the panes survive, inside a shell the host will never show again.
  • VdomLifecycle#executeVdomUpdate documents the contract on its side (src/mixin/VdomLifecycle.mjs:366-395): a rejected flight adopts no vnode and rejects every parked promise so "the next cycle re-diffs cleanly" — the dock is the caller that has to USE that cleanliness and does not.
  • isChildUpdating / isParentUpdating serialize ancestor and descendant flights (VdomLifecycle.mjs:692-745), so the host's phase updates queue behind a workspace-level flight rather than merging into it; a silent structural removal made between two flights is exactly what a landing ancestor flight cannot know about.

The Fix

  1. Fail-safe transaction. In reconcileProjection, guard phases 2–4: on rejection, retire the staged shell if it is still hidden (or finish the swap if the retained chrome already moved), un-hide the outgoing shell, and re-throw a typed error the workspace handles by scheduling ONE clean refreshDockWorkspace against the committed document (the reject-then-re-diff contract above). Workspace#refreshDockWorkspace / onDockZoneDocumentChange surface the failure (a dockProjectionFailed event plus the existing warn path) instead of an unhandled rejection.
  2. Close the observed race once characterized: either make the silent leaver retirement publish through the bar before an ancestor flight can land (enroll the retiring bar in commitBars, the way cross-bar moves already are, Reconciler.mjs:733-740), or let VNode.createMap skip a reference whose component is destroyed (the removal delta follows in the next flight) — a maintainer call between fail-loud and degrade; the ticket does not pre-empt it.

Acceptance Criteria

  • A unit or component spec rejects one host update inside reconcileProjection (phase 2 and phase 3 arms) and asserts: exactly one shell in the host afterwards, visible, at shellIndex; every pane of the committed document reachable; the workspace's next commit reconciles normally.
  • The failure is observable through an event or the workspace's warn path; no unhandled rejection.
  • The stale-reference rejection is either reproduced with a red-first arm (a workspace-level cls write landing during phase 2) and closed, or characterized in the PR body with the guard from AC 1 as the shipped mitigation.
  • Post-merge, hand check: three pin → rail → pin-back cycles on a right-edge pane in apps/workstation and in a consumer host with a heavy DOM leave one shell.

Out of Scope

  • The preview overlay's in-flow layout (separate ticket).
  • FLIP timing and MotionSignal fail-safe duration as such.

Related

  • Epic #13158 (docking polish); the reconciler notes on in-flight collisions in src/dashboard/dock/Workspace.mjs (search "collision that duplicated retained chrome").
  • #14911 (closed) — a destroyed child's DOM surviving a wholesale container refresh: the same family of "destroyed component still referenced by a landing tree", resolved at the container level; the dock's transaction sits one layer above it.

Live latest-open sweep: checked the latest 20 open issues at 2026-09-02T21:31:05Z; no equivalent found. A2A claim sweep over the last 30 messages: no overlapping lane claim.

Origin Session ID: f6da5bf6-e688-454c-8fea-1ee269327016

Retrieval Hint: "reconcileProjection rejected promiseUpdate two shells Component not found tab header button"

tobiu referenced in commit 4d9c6b2 - "fix(dock): a rejected projection phase settles the host to one shell and reports it (#18143) (#18147) on Sep 3, 2026, 12:15 AM
tobiu closed this issue on Sep 3, 2026, 12:15 AM