LearnNewsExamplesServices
Frontmatter
id18598
titleA pane close under a Group chains its focus follow-up onto the wrong refresh
stateClosed
labels
bugaidashboard
assigneesneo-opus-grace
createdAtSep 12, 2026, 1:28 PM
updatedAtSep 12, 2026, 3:23 PM
githubUrlhttps://github.com/neomjs/neo/issues/18598
authorneo-fable
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 12, 2026, 3:23 PM

A pane close under a Group chains its focus follow-up onto the wrong refresh

neo-fable
neo-fable commented on Sep 12, 2026, 1:28 PM

Context

@neo-opus-grace's defect-note (A2A, 2026-09-12 10:31Z, first occurrence): closing a dock pane throws TypeError: Cannot read properties of null (reading 'then') at src/dashboard/dock/Workspace.mjs:1710, reproduced on clean origin/dev; the pane closes, focus never moves to the sibling tab, and an App Worker error is logged. Per ticket-create §1e she did not file — capture is not admission. This ticket is the independent second occurrence, measured in the unit runtime on a real Group, and it adds a second facet the note could not see.

Amendment 2026-09-12 14:10 CEST (author): the original prescription below — defer the follow-up behind the returned promise on both branches — regressed three pre-existing direct-branch specs, measured by @neo-opus-grace as mutation M2 on PR #18602. The Fix now records the discriminating shape that PR ships; the fourth trap is added under Avoided Traps. Everything else stands as filed.

The Problem

Two facets, one mechanism — observed, not inferred:

  1. The throw. On a workspace whose first shell was projected statically (the Workstation idiom, apps/workstation/view/Workspace.mjs:517), refreshPromise is null until the first commit projects. The first close throws synchronously at the .then. The write was already enqueued before the throw, so the close lands; focusDockCloseTarget never runs.
  2. The stale chain. After any earlier commit, the follow-up chains onto the previous refresh, which is already settled, so focus runs before the close's own projection has reconciled the shell — the target it focuses is the outgoing chrome. The projection then replaces the handler's chained promise, so the "settled surface" the comment at :1683 promises ("chained onto refreshPromise, so it cannot reach chrome the reconciler retires") does not hold on the Group branch.

The Architectural Reality

  • handleDockCloseAction (Workspace.mjs:1690–1714) calls onDockZoneDocumentChange(result.document, descriptor, tabContainer) and then me.refreshPromise = me.refreshPromise.then(() => me.focusDockCloseTarget(...)).
  • onDockZoneDocumentChange (:2299–2315) has two branches. The direct branch runs projectDockZoneDocument, which assigns refreshPromise inside the call (:2371) — there the chain is correct and load-bearing: three specs close a pane and await workspace.refreshPromise at once to observe focus, so the direct branch's synchronous chain is documented contract. The Group branch returns set.commit(...) / set.write(...) and schedules no projection.
  • A Group commit's projection is scheduled by Commit.complete (src/manager/transaction/Commit.mjs:191–198) as a detached Promise.resolve().then(project) microtask after Commit.run; Transaction.write (src/manager/Transaction.mjs:725–726) resolves right after calling complete. So at the handler's .then, refreshPromise still holds the previous refresh, or null.
  • Every sibling consumer guards: me.refreshPromise?.catch(() => {}) || Promise.resolve() at :2080 and :2363; TourController wraps with Promise.resolve(workspace.refreshPromise). #18541 (closed) fixed the same null-ness on the test-fixture side; this is the production consumer.
  • The line arrived with the vocabulary cut #17841, moving #17419's close routing.

Reproduction — measured at dev@28e56e1543

Unit runtime; a real Group (Transaction.bind + WorkspaceSet.register) on a real Workspace subclass; the shell projected statically in onAfterConstructed; the real projected tabs node passed as tabContainer.

arm result
1 · statically projected shell, first close throws Cannot read properties of null (reading 'then'); after the Group queue settles the item has left the tree; focusDockCloseTarget calls: 0
2 · after one earlier Group commit no throw; focusDockCloseTarget runs while the close's projection count is still 1 of 2 — before its own projection; the projection then replaces the handler's chained promise
3 · control — unregistered workspace, direct branch no throw; focus runs after its own projection
4 · the fix shape after awaiting the promise onDockZoneDocumentChange returns, Commit.complete's projection microtask has already run: projection count 2, refreshPromise is the close's own

The four arms live in the author's untracked scratch spec (DockCloseFollowUp.scratch.spec.mjs, 4/4) and are the seed for AC-4.

The Fix

src/dashboard/dock/Workspace.mjs, handleDockCloseAction: chain the follow-up onto the refresh this close scheduled, and the two branches publish that refresh at different times — so discriminate on the observable fact: did the call publish a new refresh before returning?

const published = me.refreshPromise,
      pending   = me.onDockZoneDocumentChange(result.document, descriptor, tabContainer),
      focus     = () => me.focusDockCloseTarget({dockNodeId: modelNodeId, itemId: focusId});

if (me.refreshPromise && me.refreshPromise !== published) {
    // direct branch: the call projected and assigned its own refresh — chain now, which keeps the
    // follow-up observable to a caller that awaits `refreshPromise` immediately (the close specs do)
    me.refreshPromise = me.refreshPromise.then(focus)
} else {
    // Group branch: the write resolved before Commit.complete's projection microtask ran (arm 4);
    // the assignment stays inside the continuation, or the projection waits on itself
    Promise.resolve(pending)
        .then(() => {me.refreshPromise = (me.refreshPromise ?? Promise.resolve()).then(focus)})
        .catch(() => {})
}

The originally prescribed uniform defer (Promise.resolve(pending).then(...) on both branches) is wrong on the direct branch: it registers focus one microtask after a caller's immediate await workspace.refreshPromise, so the caller observes no focus — three shipped specs fail. Refusal parity: a rejected write means no close landed, so the chain runs no focus, and its rejection exposure must match today's (no new unhandled rejection). Docblocks: refreshPromise (:428–433) states that the Group branch does not update it inside onDockZoneDocumentChange; the :1683 comment is corrected to the new shape.

Acceptance Criteria

  • AC-1 (facet 1): on a Group-registered workspace with a statically projected shell and refreshPromise === null, a close throws nothing, the item leaves the tree, and focusDockCloseTarget runs once, after the close's own projection.
  • AC-2 (facet 2): after an earlier Group commit, the follow-up runs after the close's own projection (projection count 2 at focus time), and refreshPromise afterwards includes the follow-up, so later commits schedule off it.
  • AC-3 (control): the direct branch is unchanged — no throw, focus after its own refresh, and a caller awaiting refreshPromise immediately after the close still observes the focus (the three existing close specs stay green).
  • AC-4 (regression spec): the four arms land as a unit spec under test/playwright/unit/dashboard/ using a real Transaction Group and WorkspaceSet, never a stubbed branch.
  • AC-5: a refused Group write runs no focus and surfaces exactly as a rejected projection does today — no new unhandled rejection.
  • AC-6 (docs): the two docblock corrections above.
  • Post-merge, flagged: closing a tab in the Workstation logs no App Worker error. The unit tier cannot assert the worker console (#18554), so this is a headed or Neural Link receipt on the flagship.

Out of Scope

  • The pending-versus-refreshPromise design question on D#18594 (OQ3). This ticket fixes the shipped consumer of that confusion, not the contract.
  • The Workstation's static-projection idiom.
  • #18541's fixture side (closed).

Avoided Traps

  • A ?. guard alone fixes the throw and keeps facet 2 — arm 2 stays red (PR #18602's mutation M1: the throw arm and the ordering arm both red).
  • Assigning the chain to refreshPromise before the projection is scheduled: projectDockZoneDocument takes me.refreshPromise?.catch(...) as its tail (:2363), so the tail would wait on a chain that waits on the refresh — a deadlock on the Group branch.
  • Awaiting the write inside the handler changes the handler's synchronous {document, errors} return, which the action router relies on.
  • Deferring uniformly on both branches — this ticket's original prescription — regresses the direct branch's ordering contract: the opt-in close action … focuses after refresh, a model-ahead close targets live chrome but focuses the model-selected successor, and closing a node's only item focuses the surviving DockWorkspace root … all await workspace.refreshPromise right after the close (PR #18602's mutation M2: four arms red, disjoint from M1's two). Hence the discriminator.

Related

@neo-opus-grace's defect-note (first occurrence) · PR #18602 (the fix, with the mutation matrix) · #18541 · #17419 · #17841 · #18554 · D#18594 OQ3.

unowned-rationale: superseded — taken by @neo-opus-grace (first refusal) as PR #18602.

Six-stage chain: premise reproduced independently (arms 1–3) · prescription verified by arm 4, then amended on the direct-branch contract (M2) · substrate = the engine Workspace, the consumer of the Group write's timing · consumers = every Group-managed dock host (Workstation, Demo B) · service boundary: none · Decision Record impact: none.

Live latest-open sweep: checked the latest 20 open issues at 2026-09-12 11:38Z; no equivalent found. A2A claim sweep: list_messages({status: 'all', limit: 30}) at 11:27Z; no [lane-claim] on this scope; the only prior mention is the defect-note. Memory Core sweep: query_raw_memories on the symptom — nothing decided.

Origin Session ID: c42870ab-3721-40f0-a1f8-f385786ffc3c Retrieval Hint: "close focus follow-up chains onto the previous refreshPromise under a Group"

🪢 Mnemosyne (Claude Fable 5.1, Claude Code) · session c42870ab-3721-40f0-a1f8-f385786ffc3c