LearnNewsExamplesServices
Frontmatter
id16391
titleDockFlip misclassifies same-node splitter resizes as replacement trees
stateClosed
labels
bugai
assigneesneo-kimi-phoebe
createdAtAug 2, 2026, 8:18 PM
updatedAtAug 3, 2026, 12:05 AM
githubUrlhttps://github.com/neomjs/neo/issues/16391
authorneo-fable
commentsCount0
parentIssuenull
subIssues
16412 Tour resets pass geometryOnly without verifying topology stability
subIssuesCompleted0
subIssuesTotal1
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 3, 2026, 12:05 AM

DockFlip misclassifies same-node splitter resizes as replacement trees

neo-fable
neo-fable commented on Aug 2, 2026, 8:18 PM

Context

While instrumenting #16375 (frame-by-frame timeline across committed splitter drags, receipts in its round-3/round-4 comments), the probe measured DockFlip taking the wrong branch for same-node resizes on every committed drag. This is a presentation-side defect, independent of the #16375 grid-geometry repair (PR #16390) — that PR makes worker geometry immune to the visual window; this ticket shrinks and corrects the window itself.

The Problem

For a committed splitter resize, the marker elements survive in place: same nodes, same ancestor lineage, only their layout boxes change. hasPreservedMarkerSet() (src/main/addon/DockFlip.mjs:136) requires an ancestor-lineage CHANGE to classify a preserved set — deliberately, to avoid mistaking a not-yet-swapped outgoing tree for the landed state during structural moves. A resize never changes lineage, so play() classifies it as a replacement-tree case and enters stage A (:443): a bounded rAF poll (maxFrames = 15) waiting for the old tree to detach — which never happens, because the old tree IS the new tree.

Measured consequences (one committed +160px drag, 60fps-class headed run; timeline in #16375 issuecomment-5159492791):

  1. The committed layout lands and is exposed unanimated for ~300ms (the full 15-frame stage-A burn: motion-signal class at frame 86, inverse transform installed at frame 100).
  2. The panes then snap visually back to their pre-drag geometry (inverse transforms: grid scale(0.708), neighbor scale(2.627) translate(-160px)) and animate forward over 280ms — the user sees the resize land, jump back, and land again (a double-take on every committed splitter drag).
  3. The transform window this creates (invert + play + cleanup) is where async geometry readers used to ingest scaled fiction (the #16375 poison). PR #16390 hardens the grid readers, but every OTHER getBoundingClientRect consumer racing this window still reads visual lies — shrinking the window by ~40% reduces that exposure class-wide.

The Architectural Reality

  • src/main/addon/DockFlip.mjs:136hasPreservedMarkerSet(): preserved identity alone is deliberately insufficient (the stage-A comment at :140-144 explains the outgoing-tree false-positive for MOVES); the resize case needs a different discriminator, not a relaxation of this one.
  • src/main/addon/DockFlip.mjs:443-448 — stage A polls while first.els.every(el => el.isConnected); for a resize this holds until maxFrames exhausts.
  • src/main/addon/DockFlip.mjs:468-470 — the extra settle frame for "replacement trees" adds one more exposed frame to the resize path.
  • The FLIP contract (class JSDoc :24-26) is presentation-only and fail-safe; any repair must preserve the instant-landing fallback paths and the reduced-motion collapse.

The Fix

Add a geometry-based classifier at play() entry, alongside the lineage-based one: when the marker set is node-identical with unchanged lineage AND at least one marker's current rect already differs from its First rect beyond the existing motion epsilons (:500), the swap has landed in place — skip stage A and the replacement-tree settle frame, and invert immediately. The lineage-change branch stays untouched for structural moves; sets that are node-identical with unchanged geometry keep polling (the outgoing-tree case the :140-144 comment guards).

Expected effect: the inverse transform installs within ~1 frame of the committed layout (no exposed-Last window, no double-take), and the total transform window shrinks from ~660–950ms to roughly the 280ms play itself.

Acceptance Criteria

  • After a committed splitter drag, the committed layout may sit exposed without its inverse for at most ~34ms (the 2-frame budget at a 60Hz cadence — asserted as time because rAF cadence is host-dependent; a 120Hz host reads the identical residual as 2-3 frames). No ~15-frame/150-300ms exposed-Last window. (Restated from the original frame-count phrasing per PR #16403's witness rationale — accepted by the ticket author.)
  • The committed-drag visual sequence is monotonic: no snap-back frame where a pane's visual width returns to its pre-drag value after the new layout painted (rect time-series witness, e.g. observe_motion or a page-side rAF sampler).
  • Structural dock moves (cross-boundary, popup convert, tab re-dock) keep their existing stage-A behavior — the WorkstationNL drag/identity spec and the FiveBeat drag-family stay green.
  • Reduced-motion and missing-token paths still land instantly (existing fail-safe contract).
  • A regression witness pins the resize classification (red on the current stage-A burn, green with the discriminator).

Contract Ledger

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
Neo.main.addon.DockFlip#play({geometryOnly}) (remote, app scope) — optional param added by PR #16403 This ticket + the #15137 ambiguity pin (unit/dashboard/DockFlip.spec.mjs:271) Consumer-declared geometry-only projection: an exact, lineage-unchanged marker set whose rects moved beyond the motion epsilons classifies as landed-in-place (hasLandedInPlace), bypassing stage A + the replacement settle frame. Without the declaration, a geometry-moved same-parent set stays ambiguous by contract and keeps the bounded wait. Default false — fully backward-compatible. Omitted/false → pre-existing behavior unchanged; mis-declared true over a real swap → exactSet/lineage guards reject the bypass, worst case = documented fail-safe instant landing play() JSDoc param block + hasLandedInPlace summary WorkstationDockFlipResizeNL (red 19f/158ms → green ≤2f; reviewer re-run 1f/10ms) + 2 unit pins (bypass / retention) + #15137 pin unmodified

Out of Scope

  • Grid worker-geometry integrity — repaired at the consumer seam in PR #16390 (#16375).
  • The zero-rect fixed-stage staging family (#16356).
  • The drag-start wedge (#16365) and text-selection suppression (#16362).

Related

Related: #16375 · PR #16390 · #16356 · #16353

Live latest-open sweep: checked latest 20 open issues at 2026-08-02T18:20Z; no equivalent found. A2A in-flight claim sweep: last 15 messages scanned, no overlapping lane-claim.

Origin Session ID: 3bdbcbb5-b77b-46f5-88b7-9dbb124733fe

Retrieval Hint: "DockFlip same-node resize stage-A burn exposed-Last double-take"

tobiu referenced in commit 291d9df - "fix(dock): classify same-node resizes as landed-in-place, skipping the stage-A double-take (#16391) (#16403) on Aug 3, 2026, 12:05 AM
tobiu closed this issue on Aug 3, 2026, 12:05 AM