LearnNewsExamplesServices
Frontmatter
id19521
titleA dock commit that moves a tabs node across the root leaves it hidden
stateClosed
labels
bugaidashboard
assigneesneo-opus-ada
createdAtOct 9, 2026, 7:42 PM
updatedAtOct 9, 2026, 8:47 PM
githubUrlhttps://github.com/neomjs/neo/issues/19521
authorneo-opus-ada
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtOct 9, 2026, 8:47 PM
milestonev13.2

A dock commit that moves a tabs node across the root leaves it hidden

neo-opus-ada
neo-opus-ada commented on Oct 9, 2026, 7:42 PM

Context

Found while covering #19516's swap for tab-root shells (Euclid's review packet). The fixture is dock-preview-geometry: a dock whose root is a tabs node, with no app stylesheet. Commits go through the real reducer and refresh loop via its applyOperationJson trigger (added on #19516). Measured on dev 3ab4a3f158 and on #19516's head e93602e0ed:

Commit Observed on dev
Nest: splitNode puts aside beside the tabs root root The root tab container (Main) moves into the new split with visibility: hidden. Its 637 px half stays blank and Main never repaints. On #19516 the swap's retiring class also gives it display: none.
Un-nest: split s [t1, t2] collapses to t1 (moveItem aside → t1) The old split stays the host's shell: visible, 1280 px, its leftover tabs node hidden. The re-rooted t1 (Main, Aside) is a second host child at x=1280. The workspace is blank. Identical on #19516.

Both states persist until another commit re-projects.

The Problem

Reconciler#reconcileProjection decides which shell is outgoing after moveRetainedTabChrome:

nextShell    = nextShell.isDestroyed ? host.items[shellIndex] : nextShell;
retainedRoot = oldShell === nextShell;
// !retainedRoot: oldShell hidden, nextShell shown; later host.remove(oldShell)

That rule was written for a tabs root that stays the root. Its comment reads: "the retained tab is both old and new shell; never retire it as source chrome." A retained tabs node that crosses the root boundary breaks it both ways:

  • Nest. The old shell is itself the retained tab, and moveRetainedTabChrome moves it into the staged split. Nothing is outgoing, yet the swap hides it. The cleanup's host.remove(oldShell) is then a no-op, because the shell is no longer the host's child.
  • Un-nest. The staged root is the retained tab's placeholder. moveRetainedTabChrome destroys it and inserts the tab at the placeholder's index, shellIndex + 1. host.items[shellIndex] is still the old split, so retainedRoot reads true. No swap happens, the old split never retires, and the re-rooted tab sits beside it.

The Architectural Reality

  • Neo.dashboard.dock.projection.Reconciler#reconcileProjection: phase 3 runs moveRetainedTabChrome, then the visibility swap; phase 4 removes the outgoing shell.
  • #settleFailedProjection derives survivor and casualty from oldShell / nextShell / swapped, so it inherits the misidentification on a rejected flight.
  • #moveRetainedTabChrome(plans) re-parents each retained tab into its plan's placeholder slot. When the staged root is a placeholder, plan.placeholder === nextShell names the retained tab that becomes the root.
  • Design authority: the transaction's invariant as stated in settleFailedProjection's @summary: "one shell, at shellIndex, visible." Also the retained-root comment quoted above: a retained tab is never retired. Documents admit any root: LayoutAdapter.projectNode projects edge-zone, split and tabs, and Operations.splitNode replaces the root when its target has no parent.

The Fix

In phase 3, after moveRetainedTabChrome:

  1. The next shell is the retained tab whose placeholder was the staged root (the plans entry with placeholder === nextShell), not host.items[shellIndex].
  2. The outgoing shell is the old shell only while it is still the host's child. A root tab re-homed into the staged shell is not outgoing: nothing is hidden, retired or removed.
  3. settleFailedProjection receives that outgoing shell, null when re-homed. With no outgoing shell, the staged shell, which holds the re-homed tab, survives a rejected flight.

Acceptance Criteria

  • AC-1: after the nest commit, both tab containers are visible and in layout, and the root tab container is the same component. Component-tier witness on the bare-consumer fixture, red on dev.
  • AC-2: after the un-nest commit, the host holds exactly one shell at shellIndex: the re-rooted tab container, visible, at x=0. The old split is destroyed. Red on dev.
  • AC-3: the same-root path (activating a tab on a tab root) is unchanged. The existing DockProjectionReconciler.spec pins pass: the retained root tab and the rejected-flight phases.
  • AC-4: a rejected phase-3 flight of a nest commit settles to one visible shell that holds the re-homed tab (unit pin).

Out of Scope

  • The one-frame blank at a commit (#19514 / #19516).
  • The Workstation's default document. Its root is an edge zone, so these commits do not cross its root.

Related

  • #19516: the swap's out-of-layout class. This defect predates it; that PR changes only the CSS outcome of the nest case.
  • #16152 (closed, not planned): a transient drain while retained tabs reparent, a different symptom.

Live latest-open sweep: checked latest 20 open issues at 2026-10-09T17:42Z; no equivalent found. A2A claim sweep: last 30 messages, no claim on dock root nesting. MC sweep: "dock tab root split pane hidden after commit retained tab container root boundary Reconciler nextShell retainedRoot", 6 results, no prior decision (one records "any root" as the dock-zone design). KB ticket sweep: no equivalent. Own-assignment sweep: 6 open, #19514 adjacent (above), none overlapping.

Origin Session ID: fcc49158-63cf-4317-a407-741c5e5e3d8c Retrieval Hint: "tab root nest un-nest Reconciler retainedRoot moveRetainedTabChrome hidden shell"