diff --git a/packages/studio/src/hooks/timelineTrackVisibility.test.ts b/packages/studio/src/hooks/timelineTrackVisibility.test.ts index d44a68d93..a6695d724 100644 --- a/packages/studio/src/hooks/timelineTrackVisibility.test.ts +++ b/packages/studio/src/hooks/timelineTrackVisibility.test.ts @@ -316,10 +316,15 @@ describe("toggleTimelineElementHidden", () => { const iframe = document.createElement("iframe"); document.body.append(iframe); const seek = vi.fn(); + const forceTimelineRebind = vi.fn(); const win = iframe.contentWindow; if (!win) throw new Error("Expected iframe contentWindow"); - const playerWindow: Window & { __player?: { seek?: (time: number) => void } } = win; + const playerWindow: Window & { + __player?: { seek?: (time: number) => void }; + __hfForceTimelineRebind?: () => void; + } = win; playerWindow.__player = { seek }; + playerWindow.__hfForceTimelineRebind = forceTimelineRebind; const files = new Map([ [ @@ -369,6 +374,7 @@ describe("toggleTimelineElementHidden", () => { expect(recordEdit).toHaveBeenCalledTimes(1); expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide element"); expect(seek).toHaveBeenCalledWith(1.25); + expect(forceTimelineRebind).toHaveBeenCalledTimes(1); expect( usePlayerStore.getState().elements.find((el) => el.key === "index.html:#hero")?.hidden, ).toBe(true); diff --git a/packages/studio/src/hooks/timelineTrackVisibility.ts b/packages/studio/src/hooks/timelineTrackVisibility.ts index 32b084613..77d1d5249 100644 --- a/packages/studio/src/hooks/timelineTrackVisibility.ts +++ b/packages/studio/src/hooks/timelineTrackVisibility.ts @@ -1,6 +1,8 @@ import { useCallback } from "react"; import { usePlayerStore, type TimelineElement } from "../player"; +import { reseekPreviewAtTime } from "../player/hooks/timelineSyncHydration"; import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements"; +import { applySoftReloadFinalization } from "../utils/gsapSoftReload"; import { timelineTrackOrder, trackDisplayNumber, @@ -106,11 +108,9 @@ function patchLiveHiddenState( } export function reseekPreviewRuntime(iframe: HTMLIFrameElement | null): void { - try { - const win: (Window & { __player?: { seek?: (time: number) => void } }) | null = - iframe?.contentWindow ?? null; - win?.__player?.seek?.(usePlayerStore.getState().currentTime); - } catch {} + const store = usePlayerStore.getState(); + if (applySoftReloadFinalization(iframe, store.currentTime)) return; + reseekPreviewAtTime({ seek: store.requestSeek }, store.currentTime); } export function groupElementsByTargetPath( diff --git a/packages/studio/src/hooks/useDomEditCommits.test.tsx b/packages/studio/src/hooks/useDomEditCommits.test.tsx index 54bdea39f..b6738fa66 100644 --- a/packages/studio/src/hooks/useDomEditCommits.test.tsx +++ b/packages/studio/src/hooks/useDomEditCommits.test.tsx @@ -981,6 +981,42 @@ describe("useDomEditCommits style persist handling", () => { } }); + it("rebinds the paused preview after a saved optimistic style commit", async () => { + stubPatchFetch({ + ok: true, + changed: true, + matched: true, + path: "index.html", + version: '"sha256:changed"', + }); + const { iframe, element } = createPreviewElement(); + const seek = vi.fn(); + const forceTimelineRebind = vi.fn(); + Object.defineProperty(iframe.contentWindow, "__player", { + configurable: true, + value: { seek }, + }); + Object.defineProperty(iframe.contentWindow, "__hfForceTimelineRebind", { + configurable: true, + value: forceTimelineRebind, + }); + usePlayerStore.setState({ currentTime: 2.4 }); + const rendered = renderDomEditCommits(createSelection(element), iframe); + + try { + await act(async () => { + await rendered.hook.handleDomStyleCommit("color", "blue"); + }); + + expect(seek).toHaveBeenCalledWith(2.4); + expect(forceTimelineRebind).toHaveBeenCalledTimes(1); + expect(rendered.reloadPreview).not.toHaveBeenCalled(); + } finally { + rendered.cleanup(); + usePlayerStore.getState().reset(); + } + }); + it("toasts and reverts a style commit when the patch request rejects", async () => { const { element, rendered, cleanup } = await commitStyleAgainst(new Error("network down")); diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index baa7a6696..b425ddb0d 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -34,6 +34,7 @@ import { } from "./useDomEditCommitsHelpers"; import type { CutoverResult } from "../utils/sdkCutover"; import { studioWriteHeaders } from "../utils/studioFileVersion"; +import { reseekPreviewRuntime } from "./timelineTrackVisibility"; interface RecordEditInput { label: string; kind: EditHistoryKind; @@ -154,6 +155,10 @@ export function useDomEditCommits({ if (options?.shouldSave && !options.shouldSave()) return; const targetPath = selection.sourceFile || activeCompPath || "index.html"; + const completePersistence = (result: T, changed: boolean): T => { + if (options?.skipRefresh && changed) reseekPreviewRuntime(previewIframeRef.current); + return result; + }; const readResponse = await fetch( `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`, @@ -201,7 +206,10 @@ export function useDomEditCommits({ if (cutover.status === "committed") { // SDK handled it — its in-memory doc is already current, so do NOT // forceReload (that would echo-reload the session we just wrote). - return { sourceFile: targetPath, version: cutover.version, changed: true }; + return completePersistence( + { sourceFile: targetPath, version: cutover.version, changed: true }, + true, + ); } } @@ -249,9 +257,12 @@ export function useDomEditCommits({ throw new DomEditPersistUnresolvableError(targetPath); } warnDomEditPersistNoOp(selection, operations); - return typeof patchData.path === "string" && typeof patchData.version === "string" - ? { sourceFile: patchData.path, version: patchData.version, changed: false } - : undefined; + return completePersistence( + typeof patchData.path === "string" && typeof patchData.version === "string" + ? { sourceFile: patchData.path, version: patchData.version, changed: false } + : undefined, + false, + ); } const patchedContent = @@ -289,11 +300,14 @@ export function useDomEditCommits({ if (!options?.skipRefresh) { reloadPreview(); } - return finalContent === patchedContent && - typeof patchData.path === "string" && - typeof patchData.version === "string" - ? { sourceFile: patchData.path, version: patchData.version, changed: true } - : undefined; + return completePersistence( + finalContent === patchedContent && + typeof patchData.path === "string" && + typeof patchData.version === "string" + ? { sourceFile: patchData.path, version: patchData.version, changed: true } + : undefined, + true, + ); }, [ activeCompPath, @@ -305,6 +319,7 @@ export function useDomEditCommits({ showToast, forceReloadSdkSession, onTrySdkPersist, + previewIframeRef, ], ); diff --git a/packages/studio/src/player/hooks/timelineSyncHydration.ts b/packages/studio/src/player/hooks/timelineSyncHydration.ts index 0878a2763..5251e0786 100644 --- a/packages/studio/src/player/hooks/timelineSyncHydration.ts +++ b/packages/studio/src/player/hooks/timelineSyncHydration.ts @@ -290,6 +290,15 @@ export function resolveReloadSeekTime(input: { return Math.min(target, input.duration); } +type SeekablePreview = Pick; + +/** Re-run the current frame even when the runtime already reports that time. */ +export function reseekPreviewAtTime(preview: SeekablePreview, time: number): void { + const target = Number.isFinite(time) && time > 0 ? time : 0; + preview.seek(target > 0.001 ? Math.max(0, target - 0.001) : 0.001); + preview.seek(target); +} + export function seekAdapterToRestorePoint( adapter: PlaybackAdapter, pendingSeekRef: { current: number | null }, @@ -303,8 +312,7 @@ export function seekAdapterToRestorePoint( }); pendingSeekRef.current = null; if (storeSeek != null) usePlayerStore.getState().clearSeekRequest(); - adapter.seek(startTime > 0.001 ? Math.max(0, startTime - 0.001) : 0.001); - adapter.seek(startTime); + reseekPreviewAtTime(adapter, startTime); return startTime; }