Skip to content

fix(studio): undo no longer snaps back a box you started dragging again - #5039

Merged
miguel-heygen merged 4 commits into
mainfrom
fix/studio-undo-refresh-spares-live-gesture
Oct 5, 2026
Merged

miguel-heygen merged 4 commits into
mainfrom
fix/studio-undo-refresh-spares-live-gesture

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

What

Undo a resize of a GSAP-animated box, then start dragging the same box and hold it. About 20 ms after the server finishes the undo, the box jumps back to where the undo put it, under your pointer. After this change, the box stays where you are dragging it.

Why

When the server confirms an undo, Studio refreshes the preview in place. It re-runs the GSAP script, re-seeks the timeline and re-applies manual edits. That writes GSAP's values over the element the new drag is holding. Studio's other preview reload already holds while a gesture is live, and it loads the file again once that gesture's save lands (useShadowPreviewReload, refreshPlayer). The undo refresh was the one path that skipped that rule.

How

usePreviewPersistence is where undo writes to the live preview. It now checks one thing: whether a gesture is live in the preview. If one is:

  • The server-confirmed refresh asks for reloadPreview() only when the last gesture ends (afterStudioManualEditGestures, the same wait the shadow reload uses). Before it reloads, it marks the restored files' scenes stale, as every other full undo reload does. That reload then waits for the gesture's save and loads the current file. Asking at once instead left the reload pending through the whole hold and blanked the timeline filmstrip. The selection is kept, because clearing it would drop the drag.
  • The predicted undo at the key press is not painted in place. The server's answer then goes through the path above.
  • A refused predicted undo is not put back in place either. It also goes through the reload.

Before

Main (1bdb5ed): resize a GSAP box, Cmd+Z, then drag the box and hold. When the undo lands, the box jumps back to the tween's position while the pointer stays put.

before-main.mp4

After

This branch: the box stays under the pointer through the undo, and the timeline filmstrip stays painted.

after-branch-trim.mp4

Real Chrome, 4 runs per build, read from the box's rect every frame:

build snap after the undo landed frames off the pointer drop saved to the file filmstrip during the hold
main 4/4 runs, 21-54 ms after about half the hold yes painted
this branch 0/4 0 yes painted

Test plan

  • Red first. Three tests in usePreviewPersistence.history.test.tsx fail on main and pass with the fix:
    • a confirmed undo does not re-seek or re-apply under a held box, hands off to the reload, and keeps the selection;
    • a predicted undo is not painted under a gesture;
    • a refused predicted undo goes through the reload once a gesture has taken the box.
  • Each guard is tested. Removing any one of the three guards fails its test.
  • 3 runs. The file passes 7/7 three runs in a row.
  • Stale scenes. A deferred reload marks the restored files' scenes stale. Deleting that call fails test 1.
  • Deferral. The confirmed and refused paths both check that no reload is asked for before the gesture ends, and that exactly one is asked for after.
  • No bench case here. A resize-then-drag on a GSAP box also lands off after a reload, a separate bug that another PR fixes. The bench sequence that catches this snap reliably needs that resize, so it lands with that fix. A move-based variant caught the snap on main in only 1 or 2 of 3 runs, so it was left out rather than added as a flaky case.

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Edit accuracy: accurate 2040 (base branch 2040), smooth 1619 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

Unstable (1)

  • crop-none-px-r0-nested-z50: tracking 0.05, pressJump 0, drop 40.07, reload 40.12, render 40.03, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0.1, reload 0.12, render 0.03, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0.1, reload 0.12, render 0.03, renderKey -, undo true, teleport true

@miguel-heygen
miguel-heygen marked this pull request as ready for review October 5, 2026 00:57

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at e9c4cc12.

The deferred refresh fires when the gesture ends. The wait is afterStudioManualEditGestures. It runs on the hf-manual-edit-gesture-ended event, which endStudioManualEditGesture dispatches, and only once no gesture is left in the document. I traced every way a gesture can end, and each one calls it:

  • a release: drag, resize and rotate end in .finally after their save settles, so the reload comes after the save, as the body says;
  • a tap that barely moved;
  • a cancel: clearPointerState ends box-size and rotation gestures, and path-offset members go through restoreManualOffsetDragMembers.

A gesture that never ended would now hold the undo's repaint too. But it would already hold the shadow reload today, so this adds no new way to get stuck. Several undos during one hold each queue a reloadPreview(). Those fire in the same event dispatch and reloadPreview is setRefreshKey(k => k + 1), so React batches them into one reload.

The three paths hold together. Under a gesture:

  • the predicted undo isn't painted;
  • a refused prediction falls back to syncHistoryPreviewAfterApply, which checks the gesture again;
  • the server-confirmed restore waits.

So whatever order the key press, the server's answer and the drag start come in, nothing paints under the held box. The gesture check comes after the nested-file reads, which is the right order, since a gesture can start during that await.

Reuse: this is the same wait the shadow reload uses (useShadowPreviewReload.ts:147-149), with the same markScenesStale + reloadPreview pair as the full undo path in applyUndoRestoreToPreview. The gesture marker is the one #5022's undo already reads. Nothing new is built.

Simplicity: one helper (heldPreviewDoc), one early return and two added conditions. That's the minimum for three entry points.

Nits, non-blocking:

  • The stale-scene path list survives a mutation. Changing restore.paths ?? Object.keys(restore.files ?? {}) to Object.keys(restore.files ?? {}) keeps all 8 tests green. Yet the refused path passes only { paths }, so that change would mark nothing stale there. An assertion in the refused-prediction test would pin it.
  • In the held case the nested-file reads are awaited and then discarded. That's harmless, just wasted work.

Tests and mutations: usePreviewPersistence* passes 8/8, three runs in a row. Mutations:

  • removing the held branch: caught;
  • reloading at once instead of after the gesture: caught;
  • dropping markScenesStale: caught;
  • dropping the predicted-undo guard: caught;
  • dropping the put-back guard: caught;
  • the path-list change: survives (nit above).

CI at this head: 60 pass, 22 pending, 0 failed when I checked.

— Rames

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 2bee99f Oct 5, 2026
165 of 166 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-undo-refresh-spares-live-gesture branch October 5, 2026 02:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants