Skip to content

fix(studio): undo shows a resize, rotate or GSAP edit undone at once while it saves - #5022

Merged
miguel-heygen merged 10 commits into
mainfrom
fix/studio-undo-paints-back-resize-rotate-gsap
Oct 4, 2026
Merged

miguel-heygen merged 10 commits into
mainfrom
fix/studio-undo-paints-back-resize-rotate-gsap

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

Pressing Cmd+Z while a resize, a rotate, or any edit on an element GSAP animates is still saving now shows the edit undone in the same frame as the key, and it stays undone until the server's undo lands. Before, the screen kept the edit until every save and the server undo had finished, often seconds under load, so the undo looked ignored. #4877 did this for a plain move and nudge; this covers the rest.

Why

Undo can only paint back an edit that registered a way to show itself undone. Only the plain move and nudge did. Three other gaps kept GSAP edits from using it:

  • A GSAP gesture writes its file after an await, and every write registered itself as a newer pending edit with nothing to paint back. Undo takes the newest, so it painted nothing, even for a GSAP move.
  • A GSAP save redraws the preview from the edited script (soft reload) before undo can step the history. A paint-back would have been drawn over: the edit flashed back on until undo's own reload.
  • A GSAP resize also draws the element live from four more places after its save: the anchored position, the CSS size when GSAP does not own the size, the crop, and the scale route's measured correction.

How

  • studioPendingEdits.ts owns the rule. An edit in flight (StudioEditInFlight) can:

    • take later writes as its own (within);
    • hold a redraw until "show again" (drawUnlessUndone);
    • run a draw on the edit as shown and paint it back again in the same task (drawKeepingUndone), so a writer that must draw to measure or to build its patches never leaves a frame showing the edit.
  • observeGsapGesture, which every GSAP gesture handler calls first, captures the edit being adopted. Its writes join that edit and carry it to the commit.

  • useGsapScriptCommits sends its preview sync through drawUnlessUndone. The file write, history, keyframe cache and SDK resync are unchanged.

  • The GSAP resize route sends its live writes through drawKeepingUndone: the position settle, the element-size commit, the anchor stage, the crop, and the scale finalize, which now measures and corrects inside one synchronous draw. The move's element-offset fallback does the same.

  • Gestures register a revert:

    • resize and rotate: the element's inline style at press, plus GSAP's x, y, rotation and scale when GSAP owns it;
    • move and nudge: GSAP's x/y now too.

    "Show again" (the server undo failed or stepped nothing) restores the last drawn look and runs any held redraw.

  • The scale route reads its drop point through the same draw, so an undo during its animation fetch cannot make it measure the undone box and save a wrong position.

  • A group move draws the members it moves on their own CSS through it too, and a move's revert also puts back the position/left/top a GSAP element's fallback move writes. The move's revert puts GSAP's x/y back from the gesture's base it holds in memory and keeps the base stamps the save reads.

  • The resize's after-save pixel check measures through the same draw, so a resize undone mid-save does not log a false commit_invariant_violation.

  • An edit that saved one write before a later one failed counts as saved, so undo still steps it on the server instead of only painting it back.

  • The undo reverts moved to components/editor/gestureUndoRevert.ts; manualOffsetDrag.ts was at its line cap.

Before

On main, with every save held 3 s, 150 ms after Cmd+Z the box still shows the edit (here, still the resized size); it snaps back only when the saves and the server undo land, 3 to 6 s later.

main: plain resize, 150 ms after Cmd+Z
main: GSAP resize, 150 ms after Cmd+Z

After

The same moment on this branch: the box is already back to its size before the edit, and stays there until the server undo lands.

branch: plain resize, 150 ms after Cmd+Z
branch: GSAP resize, 150 ms after Cmd+Z

Test plan

  • dragUndoPaint.test.ts: plain resize and rotate, a GSAP drag, and a GSAP rotate are each painted back at once and shown again. They fail on main: undo has nothing to paint back.
  • useGsapScriptCommits.test.tsx: a GSAP gesture whose write comes after an await is still one edit. After undo paints it back, its save writes the file, does not soft-reload, and soft-reloads on "show again". It fails on main (expected null not to be null), and without the redraw rule it fails on the soft reload: that is the flash.
  • gsapResizeElementSize.test.tsx: a GSAP resize painted back stays undone while its save lands and still writes the new size. On main the save draws the size back on: the resize flash.
  • studioPendingEdits.test.ts: a draw on a painted-back edit sees the edit as shown and leaves it undone; a held redraw runs only on "show again".
  • gsapResizeDropPoint.test.ts: a scale resize undone while its animations load still lands on the drop point (fails without the drop read inside the draw: 198 px off).
  • useGsapAwareEditing.groupPlan.test.tsx: a group member moved on its own CSS stays undone while the group saves.
  • dragUndoPaint.test.ts: a GSAP drag whose save draws a left/top offset stays undone while it saves.
  • dragUndoPaint.test.ts: a GSAP drag keeps its drag-start base through every repaint of its undo, so its save adds the drag once (before the fix a repaint left the base gone and a group move saved twice the drag).
  • studioPendingEdits.test.ts: an edit whose first write saved before a later one failed still counts as saved.
  • gestureTransaction.test.ts: a box undo painted back, before or during the save, is measured as the edit drew it, so no pixel change is reported.
  • Mutations, each failing its test: no resize/rotate revert, no GSAP x/y on "show again", a late write not joining its edit, a commit that always redraws, a draw that does not paint back, a draw that does not run on the shown edit, a failed edit that forgets its saved write.
  • Every touched test file passes 3 runs in a row; full Studio suite, typecheck, oxlint, oxfmt and the comment ratchet pass.

In a browser

Chrome 152 against a fixture with a 240×160 box (plain, or moved by a GSAP tween), every save held 3 s, Cmd+Z right after the edit, the box read every frame for 8 s, 3 runs per case. This branch measured at its head build:

Case main: box shows undone after this branch flash (edit shown again after undone)
plain resize 3.2–3.4 s 5–11 ms none
plain rotate 3.2 s 6–15 ms none
GSAP nudge (one 1 px key) 3.2–3.5 s 5–7 ms none
GSAP resize 6.3 s 9–13 ms none
GSAP rotate 3.2 s 1–13 ms none
plain nudge (already instant since #4877) 7–9 ms 2–6 ms none

On both builds every run ends on the pre-edit box with the file back to its pre-edit bytes.

Known limits

  • An undo pressed after a GSAP edit's preview already reloaded, while a later save of the same gesture (crop or anchor) is still running, paints the box back; a timeline re-seek before the server undo could draw the edit again. Not seen in the runs above, where undo came before the reload.
  • Undo, then a new drag on the same element within the old save's window: the old edit's late draws reset the element's inline style under the new drag.

@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

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

@miguel-heygen
miguel-heygen marked this pull request as ready for review October 4, 2026 19:31

@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 06c753d3.

Can the paint-back drift from what's saved? I followed each case through useEditHistoryActions.apply:

  • Save fails with nothing written: landed() resolves false, so the revert is the undo, and nothing gets put back. Undo waits through settlePendingEdits, which never throws, so the finally can't put the edit back after a failed save. I checked this with a throwaway test: a plain resize and a plain rotate, painted back, then the save rejects. Both end on the style from press time, and landed() is false.
  • A GSAP write saved before a later one failed: markSaved makes landed() true, so the server still steps it.
  • Plain resize: it's one commitPositionPatchToHtml call (size, crop and offset together), so it can't half-save.
  • Server refuses or steps nothing: showAgain puts back the last drawn look, and the held redraws then run against the edited file.
  • A newer edit: the newest entry is the one painted back, the same as #4877. Your second known limit covers an undo followed right away by a new drag on the same element.

Reuse: this extends #4877's pending-edit paint-back and the existing undo path. It doesn't start a second undo mechanism. The reverts reuse getOffsetDragGsap, capture/restoreStudioPathOffset and stampGestureBase. runGestureTransaction only gains an optional draw hook. elementLookRevert snapshots the whole style attribute instead of combining captureStudioBoxSize and captureStudioRotation. That's the simpler choice, and it also covers the crop's clip-path, which the box-size snapshot misses. I found nothing else this should be built on.

Simplicity: drawUnlessUndone and drawKeepingUndone can't merge into one. A redraw can wait, but the measuring draws work out the values that get saved, so they have to run on the edit as it was shown. The scale-finalize rewrite is the same steps inside one draw with shorter comments, so I don't see a smaller version.

Verified locally:

  • The 14 touched or related studio test files pass (180 tests), including both useEditHistoryActions suites.
  • I mutated studioPendingEdits.ts five ways, and each one turned tests red:
    • dropping the re-revert after a draw: 5 fail
    • markSaved doing nothing: 1 fails
    • drawUnlessUndone always drawing: 2 fail
    • within not adopting: 12 fail
    • showAgain dropping held redraws: 2 fail

Non-blocking: after showAgain, entry.revert stays null, so reverted() still returns true. If a draw ever ran after that, drawKeepingUndone would hide the edit again. Nothing reaches that today, because undo puts the edit back only after it settles. Resetting entry.revert = revert in showAgain would make the state honest, if you want to close it.

— Rames

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 49333a8 Oct 4, 2026
94 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-undo-paints-back-resize-rotate-gsap branch October 4, 2026 20:31
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