fix(studio): rebind paused preview after live edits (#3596)

This commit is contained in:
Miguel Ángel
2026-09-02 10:23:09 -04:00
committed by GitHub
parent 6b360f56f7
commit 81f1a903e1
5 changed files with 82 additions and 17 deletions
@@ -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);
@@ -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(
@@ -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"));
+24 -9
View File
@@ -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 = <T>(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,
],
);
@@ -290,6 +290,15 @@ export function resolveReloadSeekTime(input: {
return Math.min(target, input.duration);
}
type SeekablePreview = Pick<PlaybackAdapter, "seek">;
/** 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;
}