LearnNewsExamplesServices
Frontmatter
id18025
titleA tab dragged out of a group is restored into its source header, leaving two active tabs
stateClosed
labels
bugai
assigneesneo-opus-vega
createdAtSep 1, 2026, 3:36 PM
updatedAtSep 1, 2026, 7:12 PM
githubUrlhttps://github.com/neomjs/neo/issues/18025
authorneo-opus-vega
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 1, 2026, 7:12 PM

A tab dragged out of a group is restored into its source header, leaving two active tabs

neo-opus-vega
neo-opus-vega commented on Sep 1, 2026, 3:36 PM

Context

Reported by @tobiu 2026-09-01 against the Workstation on dev@96987351df, with a screenshot and — decisively — the mechanism: "look at the 2 active tabs => it is in fact the header i dragged left, getting restored."

The measured mechanism — this replaces an earlier claim in this body that the source keeps a real, restored button. It does not.

The App Worker moves the component correctly: getTabButtons() on the SOURCE toolbar no longer lists the dragged item. What survives is the item's old DOM node, never removed. So one element id — neo-tab-header-button-3 in the captured run — renders under two headers at once, and the stale node keeps the pressed class it left with. Nothing is restored, and there is no second component; the document holds a duplicate id.

Evidence, joined by bar id rather than list position:

bar toolbar-7 (new)     DOM ["Priority Alert Observatory"]                      engine tabs 1
bar toolbar-3 (source)  DOM ["Resident Activity Timeline",
                             "Priority Alert Observatory"]                      engine tabs 11
node ids in MORE THAN ONE bar: [{id: neo-tab-header-button-3, bars: [toolbar-7, toolbar-3]}]

The 11 is not an accumulation and not part of the defect: one rendered button plus ten legitimately collapsed catalog items (hidden: true, mounted: false, removeDom: true). The dragged item is not among them.

So the fix belongs in the projection delta that moves a retained button between owners — not in the sort zone's restore path, and not in the dock model, both of which behave correctly.

Live latest-open sweep at 2026-09-01T13:35:45Z: 12 newest open issues checked plus a targeted search on drag/restore/pressed/duplicate. Nearest neighbours are #16499 ("Re-entry paints the pane in both embodiments", closed, about window embodiments rather than a within-window source header) and #17889 (pointer-event sensor migration, unrelated). No equivalent open issue.

Reproduction

  1. Open apps/workstation.
  2. Drag Priority Alert Observatory out of its group — the group also holds Resident Activity Timeline — leftwards over the 100k grid.
  3. Dock it as its own pane.

Observed: the new pane renders correctly. The source header now shows both Resident Activity Timeline and Priority Alert Observatory, each with an active underline.

Expected: the source header shows only the tabs it still owns.

It is a regression, and the first bad commit is isolated

@tobiu suspected a regression and was right. Bisected against a deterministic reproduction (see below), 3 runs per probe:

commit date result
478a9fafb8 before the DockLayouts anatomy rewrite 08-29 3/3 pass
e9f10ba17e the anatomy rewrite itself (#17841) 08-29 3/3 pass
36332ff3f5 dock workspace tab drag boundary (#17926) 08-30 3/3 pass
9954179bb7 inline + standalone chrome variants (#17950) 08-31 3/3 pass
14d1024f7a project overflow into header actions (#17951 / #17960) 08-31 3/3 FAIL
937feac74e · 8cfa78702b · origin/dev 08-31 → 09-01 3/3 FAIL

14d1024f7a is the immediate child of the last good commit — git log 9954179bb7..14d1024f7a lists exactly one commit — so the boundary needs no further narrowing.

Two candidate mechanisms were ruled in/out at that boundary rather than assumed:

  • Not the drag/overflow handshake. whenProjectionIdle / onSortSnapshotReady / sortSnapshotPending appear 6 times in the tab SortZone at both the last-good and first-bad commits. That coupling predates the regression.
  • The action-contribution surface is what arrived. #17951 added addActionContribution, bindActionItems and isToolbarActionContribution to src/toolbar/Base.mjs, and addActionContribution inserts an item into the same owner.items collection the sort math indexes into (startIndex, currentIndex, moveTo(fromIndex, toIndex)). tab/header/toolbar/SortZone#moveTo already remaps item indexes to tab indexes to survive interleaved action items, which shows the hazard was anticipated there — but me.dragComponent is restored by reference, on a different path.

That is the area, not yet the line. The line-level cause is the next step and is not claimed here.

The Architectural Reality

The container/SortZone.mjs visibility-restore suspect below is RETIRED, not merely demoted. The engine's source-bar tab set does not contain the dragged item, so nothing restored it into that collection and no visibility toggle explains a duplicate DOM id. That block also last changed at 7de06abd13 (#8156) and is unchanged across the regression boundary. It is recorded here so the path is not re-walked.

A second retraction: an earlier revision claimed this was "not the doubled-subtree failure mode" on the grounds that duplicate ids were empty. That instrument was scoped within one bar while the duplication is across two, so it answered a narrower question than the one asked. This defect is in the same duplicate-id family as #17980, reached by a different route — a cross-zone move rather than a bar write racing a reconcile.

src/draggable/container/SortZone.mjs:590-595, in the drag-end path:

// Restore visibility of the dragged component (it's not in sortableItems if placeholder is used)
if (me.dragComponent) {
    let style = me.dragComponent.wrapperStyle || {};
    style.visibility = null;
    me.dragComponent.wrapperStyle = style;
}

The dragged tab button is hidden for the duration of the gesture and this restores it unconditionally. For the within-toolbar reorder the base class is written for, that is exactly right — the button never left. For a dock cross-zone move it is not: the item has been re-homed to another node, and the source's button is a component that should be retiring, not becoming visible again.

Neo.dashboard.dock.interaction.TabSortZone#processDragEnd (src/dashboard/dock/interaction/TabSortZone.mjs:1136) handles the cross-zone and tear-out terminals and calls into the base drag-end, but nothing in the path above distinguishes "the item stayed and was reordered" from "the item was re-homed" for the purposes of that visibility restore.

Why the existing witness never caught it. test/playwright/e2e/workstation/WorkstationDockSplitChromeNL.spec.mjs already records pressedCount and tabTexts per container and deliberately leaves pressedCount unasserted, documented there as belonging to "the orphaned-DOM class". Its cue commits edge-bottom on scale-tabs, and on that path every container reports pressedCount: 1 — so the arm is honest but its gesture does not reach this. The operator's gesture does.

The Fix

Not prescribed — the reachability question comes first. Candidate shapes, in the order I would test them:

  1. Scope the visibility restore to the reorder case. The base restores because it hid the component; if the terminal re-homed the item, the source's button is not the base's to un-hide. This keeps the change in the dock subclass rather than mutating shared drag semantics.
  2. Retire the source button at the cross-zone terminal, so there is nothing left for a later restore to reveal.

Option 1 is smaller and does not touch the within-toolbar contract every non-dock consumer relies on; option 2 is right if the button is genuinely leaking rather than merely being re-shown.

Acceptance Criteria

  • Dragging an item out of a multi-tab group to a new dock node leaves the source container rendering exactly the tabs it still owns — asserted on rendered chrome, cross-referenced against the committed document so a model/projection disagreement cannot pass.
  • No tab text appears in two containers at once, and no container reports more than one pressed tab, for the operator's gesture specifically.
  • The arm reds before the fix. A pressedCount assertion that passes on unfixed source does not count — and note the existing split-chrome arm passes today, so a new gesture is required rather than a new assertion on the old one.
  • The within-toolbar reorder is unchanged: dragging a tab to a new index inside one header still restores its button visible, and an arm pins that so the fix cannot be written as "never restore".
  • WorkstationDockSplitChromeNL.spec.mjs's pressedCount forensic note is updated — once this is fixed, the comment explaining why it is unasserted is stale, and the assertion may become enforceable there.
  • The reachability question is answered in the diff's comments: whether the restore fires on the re-homed component or a distinct leaked one, with the evidence that settled it.

Out of Scope

  • The drag-proxy density contract (#18020, landed as #18023).
  • Config-derived class loss on projection (#18018, landed).
  • Pointer-event sensor migration (#17889).

Refs #17836