Skip to content

fix(studio): an undo that drops the keyframes clears the transform the old preview still shows - #7005

Merged
miguel-heygen merged 1 commit into
mainfrom
dkeys/undo-leftover-transform
Nov 2, 2026
Merged

miguel-heygen merged 1 commit into
mainfrom
dkeys/undo-leftover-transform

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

What

When an undo takes the keyframes away, the preview stops showing the place the timeline had moved
the box to, instead of holding it until the reload lands.

Fixes #6989.

Why

A burst of undos could leave the box displaced by exactly the transform GSAP had committed — 240.42 px
in the issue, which is hypot(139.893, 195.508), the x/y of the gsap.set the sequence writes.

One step of that sequence takes the keyframes away, which removes a script element. No in-place patch
expresses that, so the restore asks for a full reload, and the reload runs an effect later. In that
window the live document still carries the transform the timeline composed, though the file it is
going back to places the box with CSS and no inline transform at all. A probe through the restore
path timed it: the escalation at 17.95 s, the next undo landing on the same document at 18.76 s, the
replacement promoted at about 19.3 s. The bench measures in that window, and so does a person
watching the canvas.

How

applyUndoRestoreToPreview, where it escalates to a full reload because no in-place plan exists, now
puts the authored style back — but only on the elements the restore actually stops animating:

  • nothing happens unless the restored GSAP script differs from the live one, so an undo that only
    removes an unrelated element leaves the preview exactly as it was;
  • of the elements the live timelines tween, only those the restored script no longer animates are
    repainted. Anything it still animates keeps what the timeline gave it, because the reload will
    re-render it.

It reuses restoreAuthoredStyle and collectResetTargets, the pair the soft reload already uses for
this. No other painting change: the reload still does the repair, this only stops the preview showing
a place the file no longer describes.

Size

92 lines. One scoped change at one escalation, plus the two tests that pin both halves of the scope.

Test plan

fails without the fix: applyUndoRestoreToPreview > takes GSAP's composed transform off the preview it is about to reload in packages/studio/src/utils/gsapUndoRestore.test.ts

What I measured

All on builds made with bun run build && bun run --cwd packages/cli build:copy.

on main with the change
bench case, history reply held 700 ms undo.box 240.42 every run 8 of 8 runs 0
bench case, unslowed, 40 runs the race did not land 40 of 40 runs 0
gsapUndoRestore.test.ts 1 failed, 43 passed 44 passed
whole packages/studio suite — 8277 passed, 723 files
oxfmt --check, oxlint, fallow audit --fail-on-issues, comment ratchet, tsc --noEmit — clean

The one failure on main is the keyframe-removal test; the still-animated test passes there, which is
the point of the scope.

240.42 is hypot(139.893, 195.508) and 139.893/-195.508 is the x/y of the gsap.set the sequence
writes, so the number the issue reports was that transform and nothing else.

What I did NOT exercise

  • No hand-driven browser pass; the evidence is the bench driving the built Studio.
  • The repaint is scoped to the escalation that has no in-place plan. The other escalations (a missing
    target, a permanent soft-reload failure) still reload without it.
  • Which elements the restored script still animates is read from its selectors. A script that reaches
    its targets another way (a variable holding a node list, a selector built at runtime) would read as
    dropping them, and they would be repainted a frame early.
  • Whether any other bench case was also reading the preview mid-replacement and happened to be right.

Before

The undone box on main: displaced by the transform the timeline left on the preview it was about to reload.

before

After

The same run with the change: back where the authored CSS centres it, without waiting for the reload.

after

Base automatically changed from dkeys/undo-keyframed-box to main November 2, 2026 10:15
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 120 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)

  • seqab-none-center-r0-root-z100: tracking 0.03, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true / tracking 0.03, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true / tracking 0.03, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true

@miguel-heygen
miguel-heygen force-pushed the dkeys/undo-leftover-transform branch from 5ad46f6 to 2c25d87 Compare November 2, 2026 11:13

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes at 2c25d875.

Blocker: the repaint fires on every structural undo, not just the one that drops the keyframes.

repaintWhileReloading runs whenever planRestoreTargets returns no plan. That covers every structural restore: undoing a split, a delete, or an added element. It resets every element the current timelines tween back to authored style. When the restored file keeps those tweens, the preview now shows each animated element at its authored base until the reload is promoted, then it jumps back. On main, the old frame stays correct until the swap. If the undo happens while the playhead is paused mid-animation, the user sees a visible flash on every such undo.

Repro probe (happy-dom, same harness as gsapUndoRestore.test.ts): #a has to("#a",{x:100}) with its live transform at translate(100px, 0px). The restore only removes an unrelated #b, and the script is unchanged.

  • main: #a keeps translate(100px, 0px). Passes.
  • this head: #a.style.transform becomes "". Fails.

Please scope the repaint to elements the restored source no longer animates, for example only when the restored GSAP script differs from the live one, and only for targets it drops. Add this case as a test that must stay green.

Also please fix: the requestPreviewReload() half is a no-op in the app, and the description says otherwise.

The only caller passes App's reloadPreview (usePreviewPersistence.ts:243). That goes through setRefreshKey, which already calls requestPreviewReload() synchronously (App.tsx:108-111, present on the merge base). So the claims that "nothing ever called the first half" and that Studio "answered no" are not true on main. The second new test passes only because it injects vi.fn() as reload. Either drop the call and that test, or keep the call as defense-in-depth and correct the title and the Why/How sections. The title currently describes the no-op half.

Verified:

  • The fails without the fix: line is present. The named test (takes GSAP's composed transform off the preview it is about to reload) fails with gsapUndoRestore.ts and gsapSoftReload.ts reverted to main and passes at head.
  • gsapUndoRestore.test.ts and previewReloading.test.ts both pass at head (49 tests).
  • The bench gate (swapPending) is untouched.

@miguel-heygen
miguel-heygen force-pushed the dkeys/undo-leftover-transform branch from 2c25d87 to 0d0379d Compare November 2, 2026 11:48
@miguel-heygen miguel-heygen changed the title fix(studio): say the preview is changing from the undo that asks for a reload fix(studio): an undo that drops the keyframes clears the transform the old preview still shows Nov 2, 2026
…e old preview still shows

A restore that removes the GSAP script cannot be patched in place, so the preview reloads - and until it lands it keeps the transform the timeline composed, though the file it is going back to places the box with CSS alone. Only the elements the restored script stops animating are repainted; anything it still animates keeps what the timeline gave it.
@miguel-heygen
miguel-heygen force-pushed the dkeys/undo-leftover-transform branch from 0d0379d to f0604dc Compare November 2, 2026 11:52

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving at f0604dcf. Both findings from my previous review are resolved.

  • Structural-undo flash: the repaint now runs only when the restored GSAP script differs from the live one, and it skips elements the restored script still targets. My earlier probe passes at this head: the restore removes an unrelated #b, the script is unchanged, and the still-animated #a keeps translate(100px, 0px). Your new test "leaves a still-animated element alone…" covers the same case.
  • I also tried a mixed case: the script changes and the restore is structural, with #a still tweened and #b's tween dropped. With the const tl = gsap.timeline(...) form, #a is kept and #b is cleared, as intended. With the chained gsap.timeline().to(...) form, parseGsapScript finds no selectors, so #a is also repainted early. That matches the limitation you disclosed. Studio and the SDK write the tl variable form (mutate.ts requires it), so it isn't a blocker.
  • requestPreviewReload(): the no-op call and its test are gone, and the description no longer makes that claim.
  • fails without the fix:: the named test fails with gsapUndoRestore.ts and gsapSoftReload.ts reverted to main (1 failed / 43 passed) and passes at head. gsapUndoRestore, gsapSoftReload and my probes pass: 107/107.
  • CI: Comments passes on this head. CI is still running.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 2, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 2, 2026
@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit cd59842 Nov 2, 2026
78 checks passed
@miguel-heygen
miguel-heygen deleted the dkeys/undo-leftover-transform branch November 2, 2026 12:49
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.

studio: undo/redo on a keyframed plain element sometimes leaves the box 230-400px off (edit-accuracy seqplainkeys-none-*)

1 participant