From fc897cc4f5889ad0e98dd9dab9e3ea2b93cb66d8 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 03:06:48 -0700 Subject: [PATCH] fix(studio): force-reload sdk session after undo/redo bypasses suppress window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- packages/studio/src/App.tsx | 14 ++++++------- packages/studio/src/hooks/useAppHotkeys.ts | 20 +++++++++++++++++++ .../studio/src/hooks/useSdkSession.test.ts | 12 +++++++++++ packages/studio/src/hooks/useSdkSession.ts | 17 +++++++++++++--- 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 8324548715..57ee083feb 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -145,9 +145,7 @@ export function StudioApp() { const domEditSaveTimestampRef = useRef(0); const pendingTimelineEditPathRef = useRef(new Set()); const isGestureRecordingRef = useRef(false); - const reloadPreview = useCallback(() => { - setRefreshKey((k) => k + 1); - }, []); + const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []); const fileManager = useFileManager({ projectId, showToast, @@ -155,7 +153,7 @@ export function StudioApp() { domEditSaveTimestampRef, setRefreshKey, }); - const sdkSession = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef); + const sdkHandle = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef); useEffect(() => { if (activeCompPathHydrated) return; if (!fileManager.fileTreeLoaded) return; @@ -191,7 +189,7 @@ export function StudioApp() { pendingTimelineEditPathRef, uploadProjectFiles: fileManager.uploadProjectFiles, isRecordingRef: isGestureRecordingRef, - sdkSession, + sdkSession: sdkHandle.session, }); const { activeBlockParams, @@ -259,6 +257,8 @@ export function StudioApp() { onResetKeyframes: () => resetKeyframesRef.current(), onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(), onAfterUndoRedo: () => invalidateGsapCacheRef.current(), + activeCompPath, + forceReloadSdkSession: sdkHandle.forceReload, onToggleRecording: STUDIO_KEYFRAMES_ENABLED ? () => handleToggleRecordingRef.current() : undefined, @@ -305,7 +305,7 @@ export function StudioApp() { openSourceForSelection: fileManager.openSourceForSelection, selectSidebarTab: selectSidebarTabStable, getSidebarTab: getSidebarTabStable, - sdkSession, + sdkSession: sdkHandle.session, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; @@ -322,7 +322,7 @@ export function StudioApp() { } }; useSdkSelectionSync( - sdkSession, + sdkHandle.session, domEditSession.domEditSelection, domEditSession.domEditGroupSelections, ); diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index c62b9f20cd..56cfffd98f 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -117,6 +117,14 @@ interface UseAppHotkeysParams { onDeleteSelectedKeyframes: () => void; onAfterUndoRedo?: () => 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) ── @@ -302,6 +310,8 @@ export function useAppHotkeys({ onDeleteSelectedKeyframes, onAfterUndoRedo, onToggleRecording, + activeCompPath, + forceReloadSdkSession, }: UseAppHotkeysParams) { const previewHotkeyWindowRef = useRef(null); const previewHistoryCleanupRef = useRef<(() => void) | null>(null); @@ -349,6 +359,14 @@ export function useAppHotkeys({ } if (result.ok && result.label) { 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); showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info"); } @@ -361,6 +379,8 @@ export function useAppHotkeys({ waitForPendingDomEditSaves, writeHistoryFile, onAfterUndoRedo, + activeCompPath, + forceReloadSdkSession, ], ); diff --git a/packages/studio/src/hooks/useSdkSession.test.ts b/packages/studio/src/hooks/useSdkSession.test.ts index b4b81b49b5..27155fdd4d 100644 --- a/packages/studio/src/hooks/useSdkSession.test.ts +++ b/packages/studio/src/hooks/useSdkSession.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it } from "vitest"; 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", () => { it("reloads when the changed file is the active composition", () => { expect(shouldReloadSdkSession({ path: "scenes/intro.html" }, "scenes/intro.html")).toBe(true); diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index c75632479d..9bfd64f71e 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import type { MutableRefObject } from "react"; import { openComposition } from "@hyperframes/sdk"; 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. 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( projectId: string | null, activeCompPath: string | null, domEditSaveTimestampRef?: MutableRefObject, -): Composition | null { +): SdkSessionHandle { const [session, setSession] = useState(null); const [reloadToken, setReloadToken] = useState(0); @@ -111,5 +121,6 @@ export function useSdkSession( }; }, [projectId, activeCompPath, reloadToken]); - return session; + const forceReload = useCallback(() => setReloadToken((t) => t + 1), []); + return { session, forceReload }; }