fix(studio): restore saved positions on page refresh (#801)

* fix(studio): restore saved positions on page refresh

studio-manual-edits.json was correctly persisted to disk but never read back
into memory on bootstrap. On every page refresh, studioManualEditManifestRef
started empty, so handleLoad applied an empty manifest and all saved
positions/sizes/rotations were silently discarded.

applyStudioManualEditsToPreview now reads from disk whenever the in-memory
manifest is empty. The existing readRevision guard prevents overwriting an
in-flight optimistic edit if a position change races with the disk read.

* fix(studio): close delete-all race and apply same bootstrap to motion manifest

Two follow-up fixes from review:

1. Replace edits.length === 0 with an explicit manifestBootstrappedRef boolean.
   The old condition was true in two distinct states: never-bootstrapped AND
   user-deleted-all-edits. Because the delete-all disk write is async-queued,
   there was a window where applyStudioManualEditsToPreview could read stale
   disk content and resurrect just-deleted positions. The boolean flag is set
   on the first apply and reset on project switch, cleanly separating the two
   states.

2. applyStudioMotionToPreview had the identical bug: GSAP motion edits were
   also lost on page refresh. Applied the same motionBootstrappedRef pattern.
This commit is contained in:
Miguel Ángel
2026-05-13 22:39:30 +02:00
committed by GitHub
parent 246a1911b1
commit c3f70c91db
2 changed files with 16 additions and 2 deletions
@@ -77,6 +77,8 @@ export function useManifestPersistence({
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
) => Promise<void>
>(async () => {});
const manifestBootstrappedRef = useRef(false);
const motionBootstrappedRef = useRef(false);
const studioManualEditProjectRef = useRef<string | null>(projectId);
// Keep a ref to the latest projectId so async save callbacks always read the
@@ -144,7 +146,13 @@ export function useManifestPersistence({
iframe: HTMLIFrameElement | null = previewIframeRef.current,
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
) => {
const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
// Bootstrap from disk on first apply per session; explicit flag avoids
// re-reading disk after the user deletes all edits (async write race).
const needsBootstrap = !manifestBootstrappedRef.current;
if (needsBootstrap) manifestBootstrappedRef.current = true;
const readFromDiskFirst = Boolean(
options?.forceFromDisk || options?.readFromDiskFirst || needsBootstrap,
);
if (!readFromDiskFirst) {
applyCurrentStudioManualEditsToPreview(iframe);
return;
@@ -210,7 +218,11 @@ export function useManifestPersistence({
iframe: HTMLIFrameElement | null = previewIframeRef.current,
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
) => {
const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
const needsBootstrap = !motionBootstrappedRef.current;
if (needsBootstrap) motionBootstrappedRef.current = true;
const readFromDiskFirst = Boolean(
options?.forceFromDisk || options?.readFromDiskFirst || needsBootstrap,
);
if (!readFromDiskFirst) {
applyCurrentStudioMotionToPreview(iframe);
return;
@@ -427,6 +439,7 @@ export function useManifestPersistence({
studioMotionManifestRef.current = emptyStudioMotionManifest();
studioMotionRevisionRef.current += 1;
setStudioMotionRevision((revision) => revision + 1);
manifestBootstrappedRef.current = motionBootstrappedRef.current = false;
}, [projectId]);
// ── Listen for external file changes (HMR / SSE) ──