Skip to content

fix(studio): a preview reload writes the composition as it is now, not as it was when it asked - #6998

Merged
miguel-heygen merged 5 commits into
mainfrom
dkeys/undo-keyframed-box
Nov 2, 2026
Merged

miguel-heygen merged 5 commits into
mainfrom
dkeys/undo-keyframed-box

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

A preview reload no longer writes a copy of the composition the project has already moved past, so
the undos that land while it is loading are not thrown away.

Refs #6989. It does not close that issue: see "What I did NOT exercise".

Why

A burst of undos leaves the preview showing an older version of the file than the one on disk.

One step of the keyframing sequence cannot be expressed as a patch — it takes the keyframes away,
which removes a script element — so it replaces the preview instead. Replacing it means fetching the
preview HTML and writing it over the live document, either as a scene swap or, when the film changed
outside its scenes, as a shadow reload. That fetch is in flight for a few hundred ms and the undos
that follow land inside it: each patches the live document and reports itself done, then the fetched
copy arrives and overwrites all of them with the project as it was several undos ago.

The reload only ever checked that no newer reload had superseded it. Nothing asked whether the
project itself had moved since it requested the file.

How

  • studioFileRevision() in utils/studioFileVersion.ts — the module that already owns write
    identity, and the one place every Studio write passes through — counts one step per write claimed.
    A copy of the project read at a lower revision is stale.
  • The scene swap stamps its fetch with the revision and will not write a copy whose revision has
    moved; it fetches again, bounded at five, after which the newest copy in hand wins exactly as
    before.
  • The shadow reload's heldOrStale already restarted a shadow that a gesture save had outrun; it
    now checks the revision too, which is the path this sequence actually takes.

Patches keep applying in place, so the undo path itself is unchanged and no extra reload is added to
the ordinary edit: only the choice of which fetched copy a replacement is allowed to write.

The revision deliberately does not move again on the server's echo of a write it already counted.
The echo of the write that caused a reload always arrives while the shadow is still loading, so
counting it would make every single edit reload twice.

Test plan

  • src/player/hooks/useTimelinePlayer.shadowReload.test.ts, src/utils/studioFileVersion.test.ts,
    and the whole packages/studio suite.
  • HF_SLOW_HISTORY in tests/e2e/edit-accuracy/case.mjs is new: it holds each history reply back so
    the next undo is always pressed while the previous one's preview update is still running. That
    turns this race from about one run in three into every run, and is the deterministic regression for
    it.

fails without the fix: useTimelinePlayer scene swap > swaps the composition as it is now when a write lands while the HTML is loading in packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts

What I measured

before with the fix
bench case, history reply held 700 ms 4 of 4 runs undo.box 284.41 8 of 8 runs 240.42 — the stale-fetch state is gone; the number that remains is the issue's own, from the second defect below
bench case, unslowed, 40 runs the race did not land in 36 runs 40 of 40 runs clean
useTimelinePlayer.shadowReload.test.ts + studioFileVersion.test.ts 3 failed, 60 passed 64 passed
whole packages/studio suite — 722 files, 8275 tests passed
tsc --noEmit — clean
oxfmt --check, oxlint, fallow audit --fail-on-issues, comment-ratchet — clean

Neither test is vacuous: forcing the freshness check true fails two of them, and removing the
refetch bound fails the one that pins it.

What I did NOT exercise

  • The bench case is still red, for a second and separate reason, so this does not close studio: undo/redo on a keyframed plain element sometimes leaves the box 230-400px off (edit-accuracy seqplainkeys-none-*) #6989.
    With the fix in, the deterministic lever stops producing the 284 px state and produces the issue's
    exact 240.4176 px instead. A probe in the restore path shows why: the last undo lands on a freshly
    reloaded document whose fetch was served before the last writes reached disk, and the restore then
    writes that same stale state onto it. Closing that needs the refetch to wait until no write is
    outstanding rather than refetch at once — the write-token map already models claimed-but-not-echoed
    — and that is a third mechanism in this race, left for its own change.
  • The 240.4176 px state had never been reproduced outside CI before; the lever reproduces it every
    run, which is what makes the remaining work tractable.
  • No hand-driven browser pass. The evidence is the bench driving the built Studio.

Before

The box after a burst of undos, with the history reply held back. The preview is showing the file as it was several undos earlier.

before

After

The same run with the fix. The preview is no longer a stale fetch; it is still displaced, by the second defect described above, which this change does not claim to fix.

after

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

Approve at d8ab94c8. No blockers.

Re-pin: I was asked to review eac0946e, and the head moved during my review. eac0946e..d8ab94c8 only renames SWAP_REFETCHES to SWAP_REFETCHES_BEFORE_THE_COPY_IN_HAND_WINS and drops two comments. Behavior is unchanged. At this head, useTimelinePlayer.shadowReload.test.ts and studioFileVersion.test.ts pass (64 tests) and tsc --noEmit is clean.

  • I checked the fails without the fix: claim locally. With main's sceneSwap.ts, useTimelinePlayer.ts, useShadowPreviewReload.ts and studioFileVersion.ts, "swaps the composition as it is now when a write lands while the HTML is loading" fails (expected "vi.fn()" to not be called with arguments). The shadow test and the refetch-bound test also fail there. At eac0946e, all of src/player plus studioFileVersion.test.ts pass (197 files, 2481 tests).
  • Freshness checks: sceneSwapFor checks the gate after response.text(), and swapFreshly's .then re-checks after the swap lands. So a write during either the fetch or the in-frame swap triggers a refetch. That is bounded at 5, after which the old behavior takes over. A superseded swap still bails early through !isCurrent().
  • Revision counting: every Studio write path claims a token (studioWriteHeaders or markStudioWriteToken), including the undo path in usePersistentEditHistory. Not counting the echo is the right call.
  • The body is honest that #6989 stays open, with only a ref and no Fixes.

Non-blocking:

  • Unlike the swap, the shadow path's restart in heldOrStale has no refetch bound. Under writes that never stop, it keeps reloading instead of promoting. Each of those writes already starts its own reload, so this is not a new starvation mode. A bound like the swap's would make the two paths symmetric.
  • The "What I measured" table has a leftover placeholder: the unslowed 40-run row reads PLAIN40 in the "with the fix" column. Please fill it in or remove it.

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

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

  • seqplainkeys-none-center-r0-root-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true
  • seqplainkeys-none-px-r0-root-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo false, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true
  • seqplainkeys-none-pct-r0-root-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo false, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

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