LearnNewsExamplesServices
Frontmatter
id18164
titleA pane whose home node collapsed comes home in an arbitrary zone
stateClosed
labels
bugaidashboard
assigneesneo-opus-grace
createdAtSep 3, 2026, 9:01 AM
updatedAtSep 3, 2026, 10:39 AM
githubUrlhttps://github.com/neomjs/neo/issues/18164
authorneo-opus-vega
commentsCount1
parentIssue18151
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 3, 2026, 10:39 AM

A pane whose home node collapsed comes home in an arbitrary zone

neo-opus-vega
neo-opus-vega commented on Sep 3, 2026, 9:01 AM

Context

Measured on a downstream dock consumer while verifying the pop-out round trip end to end (2026-09-03). The tear-out and the return both work; the pane just does not come back where it left.

BEFORE pop-out : ["Dokumente", "Editor", "Verbindung"]     Verbindung is the RIGHT EDGE zone
AFTER pop-out  : ["Dokumente", "Editor"]                   aux-tabs emptied and was removed
AFTER close    : ["Dokumente", "Verbindung", "Editor"]     back in the CENTRE, beside Dokumente

Same component instance returns (neo-webstudio-connection-info-1 both times, and Neo does not reuse ids), so object permanence holds. Only the placement is wrong.

The Problem

Workspace#reintegrateTearOutItem resolves the pane's home like this:

placement  = me.tearOutPlacements[itemId],
storedHome = placement && doc?.nodes?.[placement.tabsNodeId]?.type === 'tabs' ? placement.tabsNodeId : null,
fallback   = storedHome || Object.entries(doc?.nodes || {}).find(([, node]) => node.type === 'tabs')?.[0],

tearOutPlacements[itemId] records the exact pre-detach placement — captureTearOutPane's sibling discipline, and the right intent. But the storedHome guard requires that node to still exist, and for a single-pane zone it never does: detaching the last item empties the node, an empty tabs node is removed, and an emptied edge zone collapses. So storedHome is null and the fallback is the first tabs node in document order — an arbitrary zone that merely happens to be enumerated first.

The failure is therefore guaranteed, not occasional, for exactly the panes most likely to be torn out: a pane that is alone in its zone. A pane sharing a node with siblings returns correctly, because its node survives the detach.

Two consequences worth separating:

  • The user's layout silently changes. A right-edge inspector comes back as a centre tab. Nothing reports it, and a saved perspective committed afterwards persists the wrong arrangement.
  • The recorded intent is discarded. tearOutPlacements holds a tabsNodeId that is no longer resolvable, and nothing tries to reconstruct what it describes.

The Architectural Reality

  • src/dashboard/dock/Workspace.mjs — reintegrateTearOutItem(itemId, pane): the storedHome / fallback resolution above, then applyDockZoneOperation({operation: 'addTab', itemId, tabsNodeId: fallback}).
  • tearOutPlacements[itemId] is written pre-detach and deleted at the top of reintegrateTearOutItem, so the record's only consumer is the branch that cannot use it once the node is gone.
  • Node removal on empty is correct behaviour and is not in question here (docking design record §2.7): the defect is that reintegration has no way to reverse it.
  • Document owns the node vocabulary, so reconstructing a zone is a reducer concern rather than something the return path should hand-roll.

The Fix

Make the recorded placement sufficient to rebuild its own home, rather than only to find one that survived.

tearOutPlacements[itemId] should capture enough of the pre-detach position to reconstruct it — the owning zone and the node's position within its parent, not just a tabsNodeId string. On return, when the stored node is absent, reintegration recreates it in that position and adds the item there; when it is present, today's behaviour is unchanged.

The arbitrary Object.entries(...).find(...) fallback should remain only as the genuine last resort — a document with no recorded placement at all — and it is worth making that path say so rather than looking like a considered choice.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
Workspace#tearOutPlacements entry src/dashboard/dock/Workspace.mjs {tabsNodeId, index, home} — the added home carries the node's own parent slot and, for a split parent, orientation, size share and a sibling anchor absent entry → last-resort first-tabs-node, as today JSDoc round-trip arm asserting the exact zone
Workspace#reintegrateTearOutItem same restores through the recorded home; retries via the last resort when it no longer resolves node still present → unchanged path JSDoc red-first: single-pane edge zone returns to that edge
Document.captureNodeHome(document, nodeId) src/dashboard/dock/model/Document.mjs new static. Records a node's own position well enough to rebuild it. The sibling anchor is what makes the record outlive its own parent: a two-child split collapses into the sibling, so parentId is unresolvable in exactly the case the record exists for no parent slot → null JSDoc capture/restore round-trip arms
Document.restoreNodeHome(document, nodeId, home) same new static. Grafts a node back, most faithful first: recorded parent still holds the slot → re-split against the sibling → fail closed. Must treat an OCCUPIED single-slot home as unresolved rather than overwriting its occupant unresolvable → returns errors, caller decides JSDoc occupancy arm (AC-8) + fail-closed arm
Operations.restoreTab src/dashboard/dock/model/Operations.mjs new entry in the public operation vocabulary (Operations.operations 15 → 16, and consumers read that list). Distinct from addTab because the precondition is inverted: it rebuilds a missing target and delegates verbatim when the target exists target unresolvable → errors JSDoc reducer arms per resolution path

Decision Record impact

aligned-with ADR 0029 — §2.7's collapse-on-empty stays exactly as it is; this restores the inverse for a returning pane and changes no zone semantics.

Acceptance Criteria

  • AC-1 A pane that was alone in an edge zone returns to that edge zone after its vessel closes, asserted by the committed document's node topology rather than by tab order.
  • AC-2 Red-first: the same witness fails at the current head, reporting the pane in the first-enumerated tabs node.
  • AC-3 A pane that shared its node with siblings still returns to that node — the surviving-home path is unchanged.
  • AC-4 A pane alone in a centre split child returns to that position in the split, not merely to some tabs node.
  • AC-5 Object permanence is unregressed: the returning pane is the same component instance, asserted by component id, not by DOM node identity — a DOM node cannot survive a move between documents, so identity must be read off the instance.
  • AC-6 With no recorded placement at all, the last-resort fallback still returns the pane somewhere valid rather than dropping it.
  • AC-7 Exact-head unit + component suites green.
  • AC-8 A recorded home whose slot is now occupied by a different node does not overwrite that occupant: an edge zone re-docked while the pane was out keeps its new occupant, and the returning pane resolves elsewhere or through the last resort. Red-first — a single-slot write with no ownership check silently orphans the occupant, which loses a pane other than the one being restored.

Out of Scope

  • Collapse-on-empty itself; removing an emptied node stays correct.
  • The vessel lifecycle, adoption, and the declining-hook family — #18151 and its subs.
  • Restoring geometry (split sizes) beyond the node's position; a returning pane may inherit the current sizes.

Avoided Traps

  • Reading tab order as placement. The symptom surfaced as a changed tab sequence, but the assertion has to be on the document's nodes: a correct tab order over a wrong zone would pass, and the two are only coincidentally related.
  • Suppressing collapse-on-empty to keep the home alive. That would leave empty zones behind for every tear-out and contradict §2.7 — the return path is what needs to reverse it, not the detach.
  • Treating this as rare. It is guaranteed for a pane alone in its zone, which is the common tear-out case; the passing case is the one with siblings.
  • Guarding only existence, never ownership. A recorded coordinate can be invalid or taken, and restoration code naturally checks the first and forgets the second. An additive slot (a split's children) is immune; a single-value slot (an edge zone's nodeId) is not. My own AC set missed this until review — corrected as AC-8.
  • Asserting DOM identity for AC-5. A node moved between documents is necessarily re-created. I measured this and briefly read it as a lost instance; the component id is the only honest witness.

Related

  • #18151 — the epic this sits beside; distinct, because the hooks there decline while this path runs and gets the answer wrong.
  • #18153 / #18160 — the vessel seam and its engine defaults; this is downstream of a working round trip.
  • #15635 (closed) — a torn-out pane stranding in a dead vessel tree; same seam, different failure.
  • ADR 0029 §2.7 — collapse-on-empty.

Live latest-open sweep: checked the 20 latest open issues at 2026-09-03T07:00Z; duplicate sweep over reintegrateTearOutItem / tearOutPlacements / reintegration-placement returned no equivalent — #15635 is closed and concerns stranding rather than placement.

Origin Session ID: a5db4dc8-310d-4702-9bfa-3780d32e932b

Retrieval Hint: "pane returns to wrong zone after vessel close tearOutPlacements storedHome collapsed home node reintegrateTearOutItem"

tobiu closed this issue on Sep 3, 2026, 10:39 AM