LearnNewsExamplesServices
Frontmatter
id18197
titleThe tear-out opt-ins ride an overridable hook and vanish silently
stateClosed
labels
bugaiarchitecturedashboard
assigneesneo-opus-vega
createdAtSep 3, 2026, 1:17 PM
updatedAtSep 3, 2026, 3:58 PM
githubUrlhttps://github.com/neomjs/neo/issues/18197
authorneo-opus-vega
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 3, 2026, 3:58 PM

The tear-out opt-ins ride an overridable hook and vanish silently

Closed Backlog/active-chunk-20 bugaiarchitecturedashboard
neo-opus-vega
neo-opus-vega commented on Sep 3, 2026, 1:17 PM

Context

Measured on a consumer whose multi-window dock looked finished and had two entire gestures dead.

Workspace#getDockProjectionOptions is the base hook that arms tear-out:

getDockProjectionOptions() {
    return this.enableDockTearOutLifecycle
        ? {enableDockTearOut: true, ...this.tearOutHandlers}
        : {}
}

It is also the natural hook for a host to override — it is the documented way to thread projection options. A host that overrides it and returns its own object, without spreading super, discards enableDockTearOut and the whole handler bundle.

Nothing reports it. The projection succeeds, the dock renders, and the pop-out action still appears — so the host's own acceptance signal says multi-window works.

Live latest-open sweep: latest 20 open issues at 2026-09-03T11:16:37Z; no equivalent. A2A claim sweep: no competing claim.

The Problem

Two gestures die, and the third keeps working in a way that hides them.

  • Dragging a tab out of the dock becomes unreachable. enableDockTearOut is what sets allowOverdrag and enableProxyToPopup on every tab sort zone (projection/LayoutAdapter.mjs:1253-1255). Without it the proxy stays clamped to its tab strip, and the adapter's own comment states the consequence exactly: "the boundary-exit grammar reads the LIVE proxy rect, and while the proxy is clamped to the tab-strip boundary the intersection ratio never drops — the exit cannot fire." The user cannot drag past the boundary at all; it does not read as disabled, it reads as broken.
  • A dragged popup finds no target. crossWindowSortGroup becomes sortGroup on the zone (:1256), and manager.DragCoordinator keys its registry by sort group — sortZones.get(sortGroup)?.get(windowId). A null group means the zone never registers, so a popup dragged over the window highlights no drop areas.
  • The pop-out ACTION still renders, because dockPopOutActionActive derives from tearOutHandlers on the instance, not from the projection context. So the one path that does not read the context keeps working, and it is the path a host is most likely to test first.

That last point is what makes this worth a ticket rather than a consumer footnote: the failure is not just silent, it is masked by a working sibling. A host can pop a pane out, close it, watch it come home, and reasonably conclude the feature is delivered.

This is the #18151 class with an extra turn of the screw — the engine does not merely ask a host to re-derive what it knows, it hands the host a way to drop it and then reports success.

The Architectural Reality

  • src/dashboard/dock/Workspace.mjs — getDockProjectionOptions() is the sole carrier of enableDockTearOut and tearOutHandlers into the context.
  • src/dashboard/dock/projection/LayoutAdapter.mjs:1253-1256 — where the three zone flags are derived from it.
  • Workspace#dockPopOutActionActive — derived from the instance bundle, which is why the action survives the loss.
  • The same method's JSDoc already documents that two related inconsistencies "throw at projection rather than silently unaddressing an action". The mechanism this ticket needs is therefore already precedented in the very method that loses the options.

The Fix

Preferred: throw at projection when enableDockTearOutLifecycle is true but the resolved projection context carries no enableDockTearOut. Same shape, same place, same rationale as the two violations already guarded there — a host that meant to enable tear-out learns immediately, and a host that meant to disable it turns the lifecycle flag off rather than dropping the options.

Alternative, if the throw is judged too strict: apply the base wiring outside the overridable hook, so the host's override composes with it rather than replacing it. Cleaner in that a host cannot get it wrong, but it makes the hook's return value no longer the whole context, which is a bigger contract change than the guard.

Either way the host-facing rule wants stating in the hook's JSDoc: an override must spread super.

Acceptance Criteria

  • AC-1 — a workspace with enableDockTearOutLifecycle: true whose getDockProjectionOptions override omits super fails loudly at projection, naming the dropped option.
  • AC-2 — a workspace with the lifecycle off and no enableDockTearOut projects silently, as today; the guard must not fire on a host that never wanted tear-out.
  • AC-3 — a workspace whose override spreads super projects unchanged, with allowOverdrag, enableProxyToPopup and sortGroup all reaching the zone.
  • AC-4 — red-first: the arm fails on current source, where the omission projects successfully.
  • AC-5 — the hook's JSDoc states that an override must spread super, and why the pop-out action cannot be used as evidence that it did.
  • AC-6 — the existing tear-out and pop-out suites stay green.

Out of Scope

  • crossWindowSortGroup having no engine default. A sort group is a deployment-wide name a host must choose, so requiring it is correct; this ticket is about the options the engine does own being droppable.
  • The vessel conversion thresholds.
  • Whether dockPopOutActionActive should derive from the context instead of the instance — arguably it should, so the action tracks the same truth as the gestures, but that is a separate design question and changing it would alter when the action hides.

Avoided Traps

  • Documenting it and calling it done. The reason this survived in a consumer for a full session is not absent documentation, it is that the pop-out action kept working and supplied a false pass. A doc line does not reach someone who already believes the feature is delivered.
  • Treating it as consumer error. It is, locally — the override should have spread super. But an engine that routes its own load-bearing wiring through a host-overridable hook, and then reports success when the host replaces it, has handed out the footgun. The guard is the engine's to add.
  • Guarding on the handler bundle instead of the context. Checking tearOutHandlers would pass in exactly the broken case: the bundle is on the instance and intact. The context is the thing that must be inspected.

Decision Record impact

none — a projection-time guard consistent with two existing ones in the same method.

Related

  • #18151 — the class: the dock asking consumers to supply or preserve what only the engine has.
  • #18153 / #18159 / #18160 — the pop-out action's own availability chain, which is what masks this.

Handoff Retrieval Hints

  • query_raw_memories: "tear-out opt-ins lost when a host overrides getDockProjectionOptions without super"
  • Anchors: Workspace#getDockProjectionOptions, LayoutAdapter zone flags at :1253-1256, DragCoordinator.sortZones, dockPopOutActionActive

Origin Session ID: e8ea32a2-b9a8-43ea-84ba-b83081ec4b3d