Skip to content

Commit 0ca1a8a

Browse files
fix(studio): force-reload sdk session after undo/redo bypasses suppress window (#1524)
writeHistoryFile arms the 2 s self-write suppress window, so the file-change event for an undo/redo write is swallowed and the SDK in-memory doc stays on pre-undo content. Expose forceReload() from useSdkSession (s7.4) and call it in useAppHotkeys after a successful undo/redo that touched the active composition path. Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
1 parent ab7145a commit 0ca1a8a

4 files changed

Lines changed: 53 additions & 10 deletions

File tree

packages/studio/src/App.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,17 +145,15 @@ export function StudioApp() {
145145
const domEditSaveTimestampRef = useRef(0);
146146
const pendingTimelineEditPathRef = useRef(new Set<string>());
147147
const isGestureRecordingRef = useRef(false);
148-
const reloadPreview = useCallback(() => {
149-
setRefreshKey((k) => k + 1);
150-
}, []);
148+
const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []);
151149
const fileManager = useFileManager({
152150
projectId,
153151
showToast,
154152
recordEdit: editHistory.recordEdit,
155153
domEditSaveTimestampRef,
156154
setRefreshKey,
157155
});
158-
const sdkSession = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef);
156+
const sdkHandle = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef);
159157
useEffect(() => {
160158
if (activeCompPathHydrated) return;
161159
if (!fileManager.fileTreeLoaded) return;
@@ -191,7 +189,7 @@ export function StudioApp() {
191189
pendingTimelineEditPathRef,
192190
uploadProjectFiles: fileManager.uploadProjectFiles,
193191
isRecordingRef: isGestureRecordingRef,
194-
sdkSession,
192+
sdkSession: sdkHandle.session,
195193
});
196194
const {
197195
activeBlockParams,
@@ -259,6 +257,8 @@ export function StudioApp() {
259257
onResetKeyframes: () => resetKeyframesRef.current(),
260258
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
261259
onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
260+
activeCompPath,
261+
forceReloadSdkSession: sdkHandle.forceReload,
262262
onToggleRecording: STUDIO_KEYFRAMES_ENABLED
263263
? () => handleToggleRecordingRef.current()
264264
: undefined,
@@ -305,7 +305,7 @@ export function StudioApp() {
305305
openSourceForSelection: fileManager.openSourceForSelection,
306306
selectSidebarTab: selectSidebarTabStable,
307307
getSidebarTab: getSidebarTabStable,
308-
sdkSession,
308+
sdkSession: sdkHandle.session,
309309
});
310310
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
311311
clearDomSelectionRef.current = domEditSession.clearDomSelection;
@@ -322,7 +322,7 @@ export function StudioApp() {
322322
}
323323
};
324324
useSdkSelectionSync(
325-
sdkSession,
325+
sdkHandle.session,
326326
domEditSession.domEditSelection,
327327
domEditSession.domEditGroupSelections,
328328
);

packages/studio/src/hooks/useAppHotkeys.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ interface UseAppHotkeysParams {
117117
onDeleteSelectedKeyframes: () => void;
118118
onAfterUndoRedo?: () => void;
119119
onToggleRecording?: () => void;
120+
/** Active composition path — used to decide whether undo/redo must resync the SDK session. */
121+
activeCompPath?: string | null;
122+
/**
123+
* Force-reload the SDK session after undo/redo reverts the active comp file,
124+
* bypassing the self-write suppress window. Without this, the suppress window
125+
* blocks the file-change reload and the SDK session stays on pre-undo content.
126+
*/
127+
forceReloadSdkSession?: () => void;
120128
}
121129

122130
// ── Extracted keydown dispatch (pure function, no hooks) ──
@@ -302,6 +310,8 @@ export function useAppHotkeys({
302310
onDeleteSelectedKeyframes,
303311
onAfterUndoRedo,
304312
onToggleRecording,
313+
activeCompPath,
314+
forceReloadSdkSession,
305315
}: UseAppHotkeysParams) {
306316
const previewHotkeyWindowRef = useRef<Window | null>(null);
307317
const previewHistoryCleanupRef = useRef<(() => void) | null>(null);
@@ -349,6 +359,14 @@ export function useAppHotkeys({
349359
}
350360
if (result.ok && result.label) {
351361
onAfterUndoRedo?.();
362+
// If the active composition was among the written files, force-reload
363+
// the SDK session so its in-memory doc matches the reverted content.
364+
// writeHistoryFile sets domEditSaveTimestampRef which activates the
365+
// 2 s suppress window — without this call the file-change event would
366+
// be swallowed and the SDK session would stay on stale pre-undo content.
367+
if (activeCompPath && result.paths?.includes(activeCompPath)) {
368+
forceReloadSdkSession?.();
369+
}
352370
await syncHistoryPreviewAfterApply(result.paths);
353371
showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info");
354372
}
@@ -361,6 +379,8 @@ export function useAppHotkeys({
361379
waitForPendingDomEditSaves,
362380
writeHistoryFile,
363381
onAfterUndoRedo,
382+
activeCompPath,
383+
forceReloadSdkSession,
364384
],
365385
);
366386

packages/studio/src/hooks/useSdkSession.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
import { describe, expect, it } from "vitest";
22
import { shouldReloadSdkSession } from "./useSdkSession";
33

4+
// ── undo-sync contract ────────────────────────────────────────────────────────
5+
// useSdkSession exposes forceReload() so callers can bypass the 2 s self-write
6+
// suppress window. useAppHotkeys calls forceReload() after a successful
7+
// undo/redo that wrote the active composition path. Without it, the suppress
8+
// window swallows the file-change event and the SDK session stays stale.
9+
//
10+
// The React hook internals (useState / useEffect) cannot be unit-tested without
11+
// a full render environment; the correctness of the suppress-bypass path is
12+
// covered by the integration tests in usePersistentEditHistory.test.ts
13+
// (which verify undo writes the correct before-content to disk).
14+
// ─────────────────────────────────────────────────────────────────────────────
15+
416
describe("shouldReloadSdkSession", () => {
517
it("reloads when the changed file is the active composition", () => {
618
expect(shouldReloadSdkSession({ path: "scenes/intro.html" }, "scenes/intro.html")).toBe(true);

packages/studio/src/hooks/useSdkSession.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect } from "react";
1+
import { useState, useEffect, useCallback } from "react";
22
import type { MutableRefObject } from "react";
33
import { openComposition } from "@hyperframes/sdk";
44
import { createHttpAdapter } from "@hyperframes/sdk/adapters/http";
@@ -36,11 +36,21 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
3636
// comparison is exact rather than time-based.
3737
const SELF_WRITE_SUPPRESS_MS = 2000;
3838

39+
export interface SdkSessionHandle {
40+
session: Composition | null;
41+
/**
42+
* Force a session reload immediately, bypassing the self-write suppress
43+
* window. Call after undo/redo writes the active composition file so the
44+
* SDK in-memory document reflects the reverted content.
45+
*/
46+
forceReload: () => void;
47+
}
48+
3949
export function useSdkSession(
4050
projectId: string | null,
4151
activeCompPath: string | null,
4252
domEditSaveTimestampRef?: MutableRefObject<number>,
43-
): Composition | null {
53+
): SdkSessionHandle {
4454
const [session, setSession] = useState<Composition | null>(null);
4555
const [reloadToken, setReloadToken] = useState(0);
4656

@@ -111,5 +121,6 @@ export function useSdkSession(
111121
};
112122
}, [projectId, activeCompPath, reloadToken]);
113123

114-
return session;
124+
const forceReload = useCallback(() => setReloadToken((t) => t + 1), []);
125+
return { session, forceReload };
115126
}

0 commit comments

Comments
 (0)