LearnNewsExamplesServices
Frontmatter
id18027
titleMaximize flashes the target rect before inverting, so the FLIP plays after a visible jump
stateClosed
labels
bugai
assigneesneo-opus-vega
createdAtSep 1, 2026, 7:29 PM
updatedAtSep 9, 2026, 1:44 PM
githubUrlhttps://github.com/neomjs/neo/issues/18027
authorneo-opus-vega
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 9, 2026, 1:44 PM

Maximize flashes the target rect before inverting, so the FLIP plays after a visible jump

neo-opus-vega
neo-opus-vega commented on Sep 1, 2026, 7:29 PM

Context

Reported by @tobiu 2026-09-01 against the Workstation on dev@2f8fc79792: "maximise => first jumps fullSize for a couple of frames, then instant jumps back to the original small size, then expands with an animation. That glitch destroys all the beauty. Broken for the other direction too, but not as bad."

Three beats, in that order, and they map one-to-one onto the current sequence.

Live latest-open sweep at 2026-09-01T17:29:17Z: 8 newest open issues plus targeted searches on maximize|maximise|FLIP|animation. Every maximize ticket is CLOSED (#17928 feature, #18003/#18008/#18021 continuity/arms, #17895 FLIP windowId routing) and none covers the arming order. No equivalent open issue.

The Problem

Mechanism corrected 2026-09-09, measured on the rendered fixture. Everything below the horizontal rule in the previous revision blamed the App-Worker→main round trip between the geometry flush and playFlip. That was wrong, and both Fix options it produced were shaped around it. The exposure is not a round trip and no delay value or re-ordering across the worker boundary was needed. The reported symptom, the ACs and the Out of Scope are unchanged.

DockFlip.play opens with a bounded outgoing-detach poll (src/main/addon/DockFlip.mjs:714):

if (!preservedMarkerSet && !landedInPlace) {
    while (!playState.interrupted && frame < maxFrames && first.els.length > 0
           && first.els.every(el => el.isConnected)) {
        await this.#nextFrame(playState);
        frame++
    }
}

It waits for the OUTGOING tree's markers to disconnect, because a replacement tree's old nodes would otherwise satisfy a naive presence-poll instantly and measure identical geometry.

A maximize has no outgoing tree. The pane paints the measured rect on the same DOM node and nothing re-projects, so every(el => el.isConnected) never falsifies and the loop spends its whole maxFrames budget — with the committed geometry already flushed and no inverse transform installed. That is the exposure, in both directions.

Measured (per-frame sampler over getBoundingClientRect() + computed transform, rendered dock-maximize fixture): 16 exposed frames on maximize (t+134→383ms) and 16 on restore (t+84→333ms) — roughly 250ms each, against the ticket's original guess of "a couple of frames". The invert arrived at frame 24 (maximize) and 22 (restore).

Both directions expose the same count. The reason the operator read restore as "not as bad" is perceptual, not temporal: a maximize flashes full-screen, a restore flashes at its own small flow slot.

The Architectural Reality

DockFlip already has two bypasses for this, and the maximize play took neither.

  • hasPreservedMarkerSet() (:171) — the native atomic-move path. Requires an ancestor lineage change as its falsifier, which a same-node resize never produces. Cannot classify a maximize.
  • hasLandedInPlace() (:201) — written for exactly this case. Its own docblock: "A committed same-node resize keeps every marker node AND its ancestor lineage, so the lineage-based hasPreservedMarkerSet() can never classify it: the replacement-tree branch would burn its whole detach poll on nodes that never detach, exposing the committed layout unanimated before the inverse installs (the double-take)."

The defect was diagnosed in-tree, and the remedy already exists. It is gated behind a consumer-declared geometryOnly flag, and Maximize#playFlip (:464) never passed it. The dock refresh path does (src/dashboard/dock/Workspace.mjs:2673, from the reconciler's actual result).

The Fix

Maximize#playFlip declares geometryOnly: true.

Corrected 2026-09-09, @neo-opus-grace in review. I first wrote that the flag is "an admission request, not an assertion — a maximize that ever does coincide with a re-projection falls back on its own." That is false and it mattered. Against a pending swap all three hasLandedInPlace() checks pass: the outgoing markers are still the exact set, their lineage is unchanged, and the transition's own geometry write supplies the movement. The addon does not re-detect a swap; geometryOnly is a consumer declaration that none can be pending, as the param's own docblock says.

The declaration holds on this path by construction, twice over — the gesture path awaits the owner's refreshPromise before applying, and every route that can run while a swap is pending (the refresh-owned reapply, operation-driven clears, fail-safes) lands with animate: false and never reaches the call. That support is stronger than the claim it replaces, and it lives at the call site, which now carries it.

It adds no mutation, so DockFlip's existing styleSnapshot cleanup keeps owning every style the transition installs — AC-6 by construction rather than by new bookkeeping.

Prior art, which reframes this ticket. hasLandedInPlace was not built for a hypothetical: it landed in fix(dock): classify same-node resizes as landed-in-place, skipping the stage-A double-take (#16391 / PR #16403, @neo-kimi-phoebe, 2026-08-03) — the same symptom name, five weeks earlier. So this is not an undiscovered mechanism; it is a consumer that never opted into a fix built for its exact class. The engine half has been correct since August.

The two options in the previous revision — returning the resolved gap so the worker could compute the invert, or moving the whole transition onto main — are both withdrawn. They addressed a mechanism that does not exist, and each would have restructured the geometry-ownership contract (rectStyle's calc() against the token is deliberate) to fix a bounded poll.

Acceptance Criteria

  • A rendered capture proves no frame paints the node at its target rect without the inverse transform, for maximize. Frame-level evidence, not a settled-state assertion — the defect is invisible to any end-state check.
  • The same for restore, asserted separately rather than assumed to follow.
  • The arm reds before the fix. An arm that only samples the settled geometry passes on unfixed source and does not count.
  • The instant path still lands with no animation and no flash — it must not acquire a transition it never had. (Config name corrected: there is no dockMaximizeMotion; the one-shot intent is Maximize#motion, set to 'instant' by operation-driven clears, fail-safes and re-projection continuity. When it is 'instant', animate is false and captureFirst/playFlip are never reached at all.)
  • The existing maximize suite stays green: component/dashboard/DockMaximize.spec.mjs and the continuity arms that #18003/#18021 hardened.
  • Whatever mutations the fix adds are owned by DockFlip's existing styleSnapshot cleanup, or by an equally idempotent path — an interrupted transition must not strand a transform.

Out of Scope

  • Whether maximize should target the viewport or the dock container, and whether it gains a gap/shadow (separate operator decision).
  • Reveal/rail preview geometry.
  • Cross-window drag previews (#17578).

Refs #17836 · #17928