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"); const iframe = document.createElement("iframe");
document.body.append(iframe); document.body.append(iframe);
const seek = vi.fn(); const seek = vi.fn();
const forceTimelineRebind = vi.fn();
const win = iframe.contentWindow; const win = iframe.contentWindow;
if (!win) throw new Error("Expected 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.__player = { seek };
playerWindow.__hfForceTimelineRebind = forceTimelineRebind;
const files = new Map([ const files = new Map([
[ [
@@ -369,6 +374,7 @@ describe("toggleTimelineElementHidden", () => {
expect(recordEdit).toHaveBeenCalledTimes(1); expect(recordEdit).toHaveBeenCalledTimes(1);
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide element"); expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide element");
expect(seek).toHaveBeenCalledWith(1.25); expect(seek).toHaveBeenCalledWith(1.25);
expect(forceTimelineRebind).toHaveBeenCalledTimes(1);
expect( expect(
usePlayerStore.getState().elements.find((el) => el.key === "index.html:#hero")?.hidden, usePlayerStore.getState().elements.find((el) => el.key === "index.html:#hero")?.hidden,
).toBe(true); ).toBe(true);
@@ -1,6 +1,8 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { usePlayerStore, type TimelineElement } from "../player"; import { usePlayerStore, type TimelineElement } from "../player";
import { reseekPreviewAtTime } from "../player/hooks/timelineSyncHydration";
import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements"; import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
import { applySoftReloadFinalization } from "../utils/gsapSoftReload";
import { import {
timelineTrackOrder, timelineTrackOrder,
trackDisplayNumber, trackDisplayNumber,
@@ -106,11 +108,9 @@ function patchLiveHiddenState(
} }
export function reseekPreviewRuntime(iframe: HTMLIFrameElement | null): void { export function reseekPreviewRuntime(iframe: HTMLIFrameElement | null): void {
try { const store = usePlayerStore.getState();
const win: (Window & { __player?: { seek?: (time: number) => void } }) | null = if (applySoftReloadFinalization(iframe, store.currentTime)) return;
iframe?.contentWindow ?? null; reseekPreviewAtTime({ seek: store.requestSeek }, store.currentTime);
win?.__player?.seek?.(usePlayerStore.getState().currentTime);
} catch {}
} }
export function groupElementsByTargetPath( 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 () => { it("toasts and reverts a style commit when the patch request rejects", async () => {
const { element, rendered, cleanup } = await commitStyleAgainst(new Error("network down")); const { element, rendered, cleanup } = await commitStyleAgainst(new Error("network down"));
+24 -9
View File
@@ -34,6 +34,7 @@ import {
} from "./useDomEditCommitsHelpers"; } from "./useDomEditCommitsHelpers";
import type { CutoverResult } from "../utils/sdkCutover"; import type { CutoverResult } from "../utils/sdkCutover";
import { studioWriteHeaders } from "../utils/studioFileVersion"; import { studioWriteHeaders } from "../utils/studioFileVersion";
import { reseekPreviewRuntime } from "./timelineTrackVisibility";
interface RecordEditInput { interface RecordEditInput {
label: string; label: string;
kind: EditHistoryKind; kind: EditHistoryKind;
@@ -154,6 +155,10 @@ export function useDomEditCommits({
if (options?.shouldSave && !options.shouldSave()) return; if (options?.shouldSave && !options.shouldSave()) return;
const targetPath = selection.sourceFile || activeCompPath || "index.html"; 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( const readResponse = await fetch(
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`, `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
@@ -201,7 +206,10 @@ export function useDomEditCommits({
if (cutover.status === "committed") { if (cutover.status === "committed") {
// SDK handled it — its in-memory doc is already current, so do NOT // SDK handled it — its in-memory doc is already current, so do NOT
// forceReload (that would echo-reload the session we just wrote). // 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); throw new DomEditPersistUnresolvableError(targetPath);
} }
warnDomEditPersistNoOp(selection, operations); warnDomEditPersistNoOp(selection, operations);
return typeof patchData.path === "string" && typeof patchData.version === "string" return completePersistence(
? { sourceFile: patchData.path, version: patchData.version, changed: false } typeof patchData.path === "string" && typeof patchData.version === "string"
: undefined; ? { sourceFile: patchData.path, version: patchData.version, changed: false }
: undefined,
false,
);
} }
const patchedContent = const patchedContent =
@@ -289,11 +300,14 @@ export function useDomEditCommits({
if (!options?.skipRefresh) { if (!options?.skipRefresh) {
reloadPreview(); reloadPreview();
} }
return finalContent === patchedContent && return completePersistence(
typeof patchData.path === "string" && finalContent === patchedContent &&
typeof patchData.version === "string" typeof patchData.path === "string" &&
? { sourceFile: patchData.path, version: patchData.version, changed: true } typeof patchData.version === "string"
: undefined; ? { sourceFile: patchData.path, version: patchData.version, changed: true }
: undefined,
true,
);
}, },
[ [
activeCompPath, activeCompPath,
@@ -305,6 +319,7 @@ export function useDomEditCommits({
showToast, showToast,
forceReloadSdkSession, forceReloadSdkSession,
onTrySdkPersist, onTrySdkPersist,
previewIframeRef,
], ],
); );
@@ -290,6 +290,15 @@ export function resolveReloadSeekTime(input: {
return Math.min(target, input.duration); 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( export function seekAdapterToRestorePoint(
adapter: PlaybackAdapter, adapter: PlaybackAdapter,
pendingSeekRef: { current: number | null }, pendingSeekRef: { current: number | null },
@@ -303,8 +312,7 @@ export function seekAdapterToRestorePoint(
}); });
pendingSeekRef.current = null; pendingSeekRef.current = null;
if (storeSeek != null) usePlayerStore.getState().clearSeekRequest(); if (storeSeek != null) usePlayerStore.getState().clearSeekRequest();
adapter.seek(startTime > 0.001 ? Math.max(0, startTime - 0.001) : 0.001); reseekPreviewAtTime(adapter, startTime);
adapter.seek(startTime);
return startTime; return startTime;
} }