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>
This commit is contained in:
Vance Ingalls
2026-06-17 16:20:52 -07:00
committed by GitHub
co-authored by Miguel Ángel
parent ab7145ad9e
commit 0ca1a8a9d1
4 changed files with 53 additions and 10 deletions
+7 -7
View File
@@ -145,9 +145,7 @@ export function StudioApp() {
const domEditSaveTimestampRef = useRef(0); const domEditSaveTimestampRef = useRef(0);
const pendingTimelineEditPathRef = useRef(new Set<string>()); const pendingTimelineEditPathRef = useRef(new Set<string>());
const isGestureRecordingRef = useRef(false); const isGestureRecordingRef = useRef(false);
const reloadPreview = useCallback(() => { const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []);
setRefreshKey((k) => k + 1);
}, []);
const fileManager = useFileManager({ const fileManager = useFileManager({
projectId, projectId,
showToast, showToast,
@@ -155,7 +153,7 @@ export function StudioApp() {
domEditSaveTimestampRef, domEditSaveTimestampRef,
setRefreshKey, setRefreshKey,
}); });
const sdkSession = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef); const sdkHandle = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef);
useEffect(() => { useEffect(() => {
if (activeCompPathHydrated) return; if (activeCompPathHydrated) return;
if (!fileManager.fileTreeLoaded) return; if (!fileManager.fileTreeLoaded) return;
@@ -191,7 +189,7 @@ export function StudioApp() {
pendingTimelineEditPathRef, pendingTimelineEditPathRef,
uploadProjectFiles: fileManager.uploadProjectFiles, uploadProjectFiles: fileManager.uploadProjectFiles,
isRecordingRef: isGestureRecordingRef, isRecordingRef: isGestureRecordingRef,
sdkSession, sdkSession: sdkHandle.session,
}); });
const { const {
activeBlockParams, activeBlockParams,
@@ -259,6 +257,8 @@ export function StudioApp() {
onResetKeyframes: () => resetKeyframesRef.current(), onResetKeyframes: () => resetKeyframesRef.current(),
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(), onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
onAfterUndoRedo: () => invalidateGsapCacheRef.current(), onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
activeCompPath,
forceReloadSdkSession: sdkHandle.forceReload,
onToggleRecording: STUDIO_KEYFRAMES_ENABLED onToggleRecording: STUDIO_KEYFRAMES_ENABLED
? () => handleToggleRecordingRef.current() ? () => handleToggleRecordingRef.current()
: undefined, : undefined,
@@ -305,7 +305,7 @@ export function StudioApp() {
openSourceForSelection: fileManager.openSourceForSelection, openSourceForSelection: fileManager.openSourceForSelection,
selectSidebarTab: selectSidebarTabStable, selectSidebarTab: selectSidebarTabStable,
getSidebarTab: getSidebarTabStable, getSidebarTab: getSidebarTabStable,
sdkSession, sdkSession: sdkHandle.session,
}); });
domEditSelectionBridgeRef.current = domEditSession.domEditSelection; domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
clearDomSelectionRef.current = domEditSession.clearDomSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection;
@@ -322,7 +322,7 @@ export function StudioApp() {
} }
}; };
useSdkSelectionSync( useSdkSelectionSync(
sdkSession, sdkHandle.session,
domEditSession.domEditSelection, domEditSession.domEditSelection,
domEditSession.domEditGroupSelections, domEditSession.domEditGroupSelections,
); );
@@ -117,6 +117,14 @@ interface UseAppHotkeysParams {
onDeleteSelectedKeyframes: () => void; onDeleteSelectedKeyframes: () => void;
onAfterUndoRedo?: () => void; onAfterUndoRedo?: () => void;
onToggleRecording?: () => void; onToggleRecording?: () => void;
/** Active composition path — used to decide whether undo/redo must resync the SDK session. */
activeCompPath?: string | null;
/**
* Force-reload the SDK session after undo/redo reverts the active comp file,
* bypassing the self-write suppress window. Without this, the suppress window
* blocks the file-change reload and the SDK session stays on pre-undo content.
*/
forceReloadSdkSession?: () => void;
} }
// ── Extracted keydown dispatch (pure function, no hooks) ── // ── Extracted keydown dispatch (pure function, no hooks) ──
@@ -302,6 +310,8 @@ export function useAppHotkeys({
onDeleteSelectedKeyframes, onDeleteSelectedKeyframes,
onAfterUndoRedo, onAfterUndoRedo,
onToggleRecording, onToggleRecording,
activeCompPath,
forceReloadSdkSession,
}: UseAppHotkeysParams) { }: UseAppHotkeysParams) {
const previewHotkeyWindowRef = useRef<Window | null>(null); const previewHotkeyWindowRef = useRef<Window | null>(null);
const previewHistoryCleanupRef = useRef<(() => void) | null>(null); const previewHistoryCleanupRef = useRef<(() => void) | null>(null);
@@ -349,6 +359,14 @@ export function useAppHotkeys({
} }
if (result.ok && result.label) { if (result.ok && result.label) {
onAfterUndoRedo?.(); onAfterUndoRedo?.();
// If the active composition was among the written files, force-reload
// the SDK session so its in-memory doc matches the reverted content.
// writeHistoryFile sets domEditSaveTimestampRef which activates the
// 2 s suppress window — without this call the file-change event would
// be swallowed and the SDK session would stay on stale pre-undo content.
if (activeCompPath && result.paths?.includes(activeCompPath)) {
forceReloadSdkSession?.();
}
await syncHistoryPreviewAfterApply(result.paths); await syncHistoryPreviewAfterApply(result.paths);
showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info"); showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info");
} }
@@ -361,6 +379,8 @@ export function useAppHotkeys({
waitForPendingDomEditSaves, waitForPendingDomEditSaves,
writeHistoryFile, writeHistoryFile,
onAfterUndoRedo, onAfterUndoRedo,
activeCompPath,
forceReloadSdkSession,
], ],
); );
@@ -1,6 +1,18 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { shouldReloadSdkSession } from "./useSdkSession"; import { shouldReloadSdkSession } from "./useSdkSession";
// ── undo-sync contract ────────────────────────────────────────────────────────
// useSdkSession exposes forceReload() so callers can bypass the 2 s self-write
// suppress window. useAppHotkeys calls forceReload() after a successful
// undo/redo that wrote the active composition path. Without it, the suppress
// window swallows the file-change event and the SDK session stays stale.
//
// The React hook internals (useState / useEffect) cannot be unit-tested without
// a full render environment; the correctness of the suppress-bypass path is
// covered by the integration tests in usePersistentEditHistory.test.ts
// (which verify undo writes the correct before-content to disk).
// ─────────────────────────────────────────────────────────────────────────────
describe("shouldReloadSdkSession", () => { describe("shouldReloadSdkSession", () => {
it("reloads when the changed file is the active composition", () => { it("reloads when the changed file is the active composition", () => {
expect(shouldReloadSdkSession({ path: "scenes/intro.html" }, "scenes/intro.html")).toBe(true); expect(shouldReloadSdkSession({ path: "scenes/intro.html" }, "scenes/intro.html")).toBe(true);
+14 -3
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import type { MutableRefObject } from "react"; import type { MutableRefObject } from "react";
import { openComposition } from "@hyperframes/sdk"; import { openComposition } from "@hyperframes/sdk";
import { createHttpAdapter } from "@hyperframes/sdk/adapters/http"; import { createHttpAdapter } from "@hyperframes/sdk/adapters/http";
@@ -36,11 +36,21 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
// comparison is exact rather than time-based. // comparison is exact rather than time-based.
const SELF_WRITE_SUPPRESS_MS = 2000; const SELF_WRITE_SUPPRESS_MS = 2000;
export interface SdkSessionHandle {
session: Composition | null;
/**
* Force a session reload immediately, bypassing the self-write suppress
* window. Call after undo/redo writes the active composition file so the
* SDK in-memory document reflects the reverted content.
*/
forceReload: () => void;
}
export function useSdkSession( export function useSdkSession(
projectId: string | null, projectId: string | null,
activeCompPath: string | null, activeCompPath: string | null,
domEditSaveTimestampRef?: MutableRefObject<number>, domEditSaveTimestampRef?: MutableRefObject<number>,
): Composition | null { ): SdkSessionHandle {
const [session, setSession] = useState<Composition | null>(null); const [session, setSession] = useState<Composition | null>(null);
const [reloadToken, setReloadToken] = useState(0); const [reloadToken, setReloadToken] = useState(0);
@@ -111,5 +121,6 @@ export function useSdkSession(
}; };
}, [projectId, activeCompPath, reloadToken]); }, [projectId, activeCompPath, reloadToken]);
return session; const forceReload = useCallback(() => setReloadToken((t) => t + 1), []);
return { session, forceReload };
} }