LearnNewsExamplesServices
Frontmatter
id15899
titleWindow-drag re-entry throws before firing on placeholder-less sort zones
stateClosed
labels
bugaicore
assigneesneo-fable
createdAtJul 25, 2026, 1:56 PM
updatedAtJul 25, 2026, 3:05 PM
githubUrlhttps://github.com/neomjs/neo/issues/15899
authorneo-fable
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtJul 25, 2026, 3:05 PM

Window-drag re-entry throws before firing on placeholder-less sort zones

neo-fable
neo-fable commented on Jul 25, 2026, 1:56 PM

Context

Direct successor to #15895, found by exactly the post-merge validation smoke that PR #15897 named (and that PR #15897's reviewer recorded as her challenge's falsifier): with min-area coverage the re-entry RATIO now crosses the threshold on a real pointer walk-back (measured 0.7898 > 0.6, armed, rising) — and the vessel still never retires. Three-hop event probes on the live gesture bisected it: dragBoundaryEntry never fires on the zone (boundaryEntrySeen=false) even though lastIntersectionRatio updates on the crossing sample — the entry branch RAN and died between the condition and the fire.

The Problem

The re-entry block in Neo.draggable.container.SortZone#checkWindowBoundary restores the in-window layout BEFORE firing the event, and that restore is written for wrapperStyle-managed zones:

  1. me.dragPlaceholder.wrapperStyle = {...} — unconditional dereference;
  2. the itemRects.forEach loop assigns item.wrapperStyle where item = me.owner.items[mappedIndex] is only guarded against placeholder/dragComponent identity, not against undefined.

Dock tab strips are placeholder-less by designNeo.dashboard.DockTabSortZone's own docs state a tab strip's geometry is owned by the committed model projection ("No dashboard layout re-flow"). Live probe on the workstation stage: placeholder=false at the crossing sample. The first restore statement throws TypeError, the exception dies inside the DOM-event dispatch (nothing reaches pageerror), fire('dragBoundaryEntry') is never reached, endWindowDrag never runs, and the gesture is stuck in window-drag with the entry condition true on every subsequent sample — throwing every time.

The existing unit fixture (test/playwright/unit/draggable/dashboard/SortZone.spec.mjs) mocks dragPlaceholder, which is exactly why the class-level tests never see this.

The Architectural Reality

  • The entry block conflates two concerns: (a) the universal gesture fact ("the drag re-entered — fire the event, the host decides"), and (b) the dashboard-family layout restore (placeholder visibility + wrapperStyle re-application). Only (b) is zone-family-specific.
  • For projection-owned zones the restore is semantically a NO-OP, not a missing feature: there is no captured in-window layout to restore, the projection owns geometry. Guarding is contract-encoding, not defensive masking (the ADR-0019 B-family concern does not apply: nothing is hidden — the zone class genuinely has no placeholder by design).
  • The exit path has no such coupling (it fires before any restore), which is why tear-out exit works everywhere while entry only ever worked on dashboard-style zones.

The Fix

In the re-entry block:

  1. Gate the layout-restore steps (placeholder visibility + the itemRects wrapperStyle loop) on me.dragPlaceholder — the marker of a wrapperStyle-managed zone.
  2. Inside the managed-zone loop, skip undefined items (mappedIndex can point past live items during transitional states).
  3. fire('dragBoundaryEntry', ...) stays unconditional — the event is the universal part.

One file (src/draggable/container/SortZone.mjs), plus a unit regression: a placeholder-less window-drag zone whose full-cover walk-in must FIRE dragBoundaryEntry without throwing (RED today: the throw swallows the fire).

Acceptance Criteria

  • Placeholder-less zone re-entry fires dragBoundaryEntry exactly once on the crossing sample (unit witness, RED→GREEN).
  • Managed-zone behavior unchanged: existing directional-logic tests green unmodified.
  • Post-merge (journey witness, e2e-not-in-CI): the workstation morph leg (scene 2 (morph), test/playwright/e2e/workstation/WorkstationFiveBeatNL.spec.mjs) passes end-to-end: vessel born mid-gesture, retired on walk-back via dockTearOutEntry, committed document byte-identical.

Out of Scope

  • Extracting the restore into an overridable zone-family method (worth considering if a third zone family appears; two call sites do not justify the seam yet).
  • The #15895 normalization semantics (shipped, correct, and confirmed working by this very investigation's ratio receipts).

Related

  • #15895 / PR #15897 (sibling defect, same entry path; this one was masked BEHIND it — unreachable ratio meant the throwing block never executed before today)
  • #15252 / PR #15840 (the workstation morph witness is the activation receipt; probe logs fivebeat-morph-probe2/3 in the production root)

Live latest-open sweep: checked latest 12 open issues at 2026-07-25T11:57Z; no equivalent. A2A herd-window sweep (10 msgs, all read-states): no overlapping claim.

Origin Session ID: b8c9e338-27b3-4385-99ac-dce459733940

Retrieval Hint: "placeholder-less sort zone dragBoundaryEntry throw morph walk-back"