Skip to content

fix(studio): undo and redo show on the next frame instead of after the server answers - #4798

Merged
somanshreddy merged 6 commits into
mainfrom
studio/fastpaint-nongsap
Oct 1, 2026
Merged

somanshreddy merged 6 commits into
mainfrom
studio/fastpaint-nongsap

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

What

Undo and redo of a style or position edit in a composition without a GSAP script now show their result on the first frame after the key, in the root composition and inside nested compositions. Before, the preview held the old state for 3 to 16 frames while Studio asked the server for the restored file. A nested one also reloaded the edited scene, and the timeline dropped to its empty state while it did.

Why

Two things made undo slow:

  • Studio stepped the server's history first and only then touched the preview: drain pending saves, read the file, POST /history/step, read the file again, apply the restore. Every round trip was paid with the old state on screen. For a restore Studio itself wrote, it already holds both sides.
  • The in-place apply only took a restore of the active composition's own file. A restore of a sub-composition file always reloaded the preview (a scene swap after a page fetch), and the full-reload path also cleared the timeline store.

How

  • The history hook remembers, per history entry id, the before and after it wrote (from the claim reply, and from each step's undoes). It offers a step's restore only while nothing has overtaken its last view of the history (a claim or a step in flight clears it).
  • On the key, the undo action shows that restore in place, in the key's own task, after taking a copy of the attributes of every element it changes. Then it steps the server as before.
  • The guess is trusted only when the server stepped the entry the tab predicted (the reply's undoes). Then the server's restore is applied as a diff from what the preview already shows, so a correct guess changes nothing. Otherwise (an agent, the CLI, another tab or a debounced save wrote in between, and the server undid that) the copied attributes are put back and the server's restore is applied against the file read from disk, as before this change. A refused step puts the copy back.
  • An outside file change Studio accepts also drops the prediction and refreshes the history view, so the common case does not paint a wrong frame at all.
  • The preview shows a restore early only when it can do it in place and synchronously: no GSAP script on either side, and no save queued or running (the save queue and the pending-edit tracker gained a synchronous idle check). Otherwise nothing is shown early, and nothing is marked stale. Each file is parsed once per side.
  • The in-place apply takes a sub-composition file too. Its markup, unwrapped from the composition template, is matched by data-hf-id onto every host the preview inlines it into ([data-composition-file]). It still reloads when the restore changes the sub-composition's own root (the preview rewrites that element), touches a sub-composition that has a GSAP script (only the active composition's script can be re-run in place), or touches a file the preview does not show.
  • A step taken before the history view caught up with the edit read no file first, so it had no "previous" and reloaded. It now starts from what the tab last wrote to that entry's files: the server only steps over an entry whose files are still as it left them.

The in-place apply is the same function the undo stack PRs rework (#4783); this changes only how it picks its targets, at the top.

Measurement

Per-frame probe in the built Studio: a rAF sample of the target's box and computed style in the live preview, plus the timeline's clip count, from the key to 2.5 s after. The fixtures are the edit accuracy bench's gsap=none crop cases at 100% zoom, on the same machine, comparing main at the base commit against this branch. "First after-frame" is the first frame after the key that shows the settled state.

case step main: first after-frame (ms) branch: first after-frame (ms) timeline min clips (main/branch)
crop px r0 root undo 3 (48) 1 (10) 1/1
crop px r0 root redo 3 (48) 1 (14) 1/1
crop px r0 nested undo 12 (278) 1 (11) 0/1
crop px r0 nested redo 11 (171) 1 (15) 0/1
crop px r30 root undo 8 (193) 1 (12) 1/1
crop px r30 root redo 5 (82) 1 (5) 1/1
crop px r30 nested undo 10 (173) 1 (12) 0/1
crop px r30 nested redo 13 (323) 1 (54) 0/1
crop pct r0 root undo 5 (113) 1 (11) 1/1
crop pct r0 root redo 3 (52) 1 (15) 1/1
crop pct r0 nested undo 13 (235) 1 (13) 0/1
crop pct r0 nested redo 11 (238) 1 (10) 0/1
crop pct r30 root undo 4 (75) 1 (9) 1/1
crop pct r30 root redo 4 (74) 1 (15) 1/1
crop pct r30 nested undo 16 (417) 1 (10) 0/1
crop pct r30 nested redo 13 (304) 1 (10) 0/1
crop center r0 root undo 4 (73) 1 (12) 1/1
crop center r0 root redo 4 (58) 1 (11) 1/1
crop center r0 nested undo 11 (233) 1 (11) 0/1
crop center r0 nested redo 11 (210) 1 (5) 0/1
crop center r30 root undo 9 (228) 1 (11) 1/1
crop center r30 root redo 4 (63) 1 (13) 1/1
crop center r30 nested undo 11 (185) 1 (12) 0/1
crop center r30 nested redo 11 (186) 1 (5) 0/1

On the branch, no frame left the settled state after reaching it, and no preview document was swapped, in any row. With the move and nudge PR (#4799) applied on top, move and nudge undo and redo are also on frame 1, root and nested.

Edit accuracy gate in CI at this head (full grid): 494 of 660 cases pass, the same as the base baseline, with none regressed. The gsap=none crop rows (36 cases, every zoom) pass undo, drop and reload in 36 of 36, as on the base. The per-frame probe above was run at the first commit; the later commits change which restores are shown early (none with a GSAP script, none after an outside write), not how a shown one paints.

Test plan

  • useEditHistoryActions.paint.test.tsx: the real history engine, preview persistence and undo action over a live preview document. Undo and redo change the element in the key's own task, before the server answers. An undo pressed while a save is running shows nothing early. Each fails when the early show is removed or the pending-save check is dropped.
  • useEditHistoryActions.paint.test.tsx: after a Studio edit, an outside write and a reload, Cmd+Z ends with the preview equal to the file the server restored. Fails without the predicted-entry check.
  • useEditHistoryActions.test.tsx: a shown step is corrected from the server's restore as a diff from what is shown; when the server stepped another entry, the shown attributes are put back and the server's restore applies as read from disk; a refused step is put back. The second fails without the predicted-entry check.
  • usePersistentEditHistory.test.ts: a step is predicted from what the tab wrote, and not while a claim or step is in flight or right after an outside change; the step's reply names the predicted entry. Fails when the outside change does not drop the prediction. An undo taken before the view caught up still reports its before and after; it fails without the fallback.
  • gsapUndoRestore.test.ts: a sub-composition file's restore lands in place on every host that inlines it; a restore of its own root, or of a sub-composition with a GSAP script, reloads. They fail without the host scope, the root guard and the script guard.
  • Studio typecheck, oxlint, oxfmt.

Before

The first repaints after Ctrl+Z, then Ctrl+Shift+Z, on a cropped element: the old crop is still on screen.

Before undo
Before redo

After

The first repaint after each key already shows the restored crop.

After undo
After redo

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Edit accuracy: 494 passing here, 494 on the base branch

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

@miguel-heygen
miguel-heygen force-pushed the studio/fastpaint-nongsap branch from 1b34a68 to e9a5316 Compare October 1, 2026 03:14
@miguel-heygen
miguel-heygen marked this pull request as ready for review October 1, 2026 03:24
@somanshreddy
somanshreddy enabled auto-merge October 1, 2026 03:30
@somanshreddy
somanshreddy added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 6ba35c9 Oct 1, 2026
102 checks passed
@somanshreddy
somanshreddy deleted the studio/fastpaint-nongsap branch October 1, 2026 03:42
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