fix(studio): swap the preview via a shadow iframe instead of blanking on reload (#4207)

* fix(studio): reloading the preview swaps in a ready copy instead of blanking

* fix(player): report painted once assets are ready and the loader has faded out

assetsready fires when the loader fade starts, so a consumer promoting on it can
expose a raised or fading panel. painted fires once per document load after the
fade completes, with a getter for late listeners; it resets on each load.

* fix(studio): promote the shadow preview only when painted, under one wait budget

Studio waited for ready plus two frames, which can land while the player's loader
panel is still up or fading. It now also waits for the player's painted event.
The shadow no longer arms the 5s adapter give-up, so a slow but live load fails
only at the single 15s budget. Set-destination availability follows the live
iframe across a swap.
This commit is contained in:
Miguel Ángel
2026-09-20 13:04:23 -04:00
committed by GitHub
parent 0c158be165
commit 85a1a8f789
21 changed files with 1485 additions and 191 deletions
@@ -2789,6 +2789,7 @@ describe("HyperframesPlayer asset-ready gate", () => {
_pendingPlay: boolean;
_paused: boolean;
assetsReady: boolean;
painted: boolean;
_waitForAssetsReady(doc: Document | null): void;
_onIframeLoad(): void;
play(): void;
@@ -2931,6 +2932,48 @@ describe("HyperframesPlayer asset-ready gate", () => {
player.remove();
});
it("fires painted only after the raised loader has faded out, not at assetsready", async () => {
const player = await createConnectedPlayer();
const { doc, video } = createStalledVideoDoc();
stubIframeContentDocument(player.iframe, doc);
const events: string[] = [];
player.addEventListener("assetsready", () => events.push("assetsready"));
player.addEventListener("painted", () => events.push("painted"));
player._waitForAssetsReady(doc);
await new Promise((resolve) => setTimeout(resolve, 160));
expect(player.hasAttribute("assets-loading")).toBe(true);
expect(player.painted).toBe(false);
video.dispatchEvent(new Event("canplay"));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(events).toEqual(["assetsready"]);
expect(player.painted).toBe(false);
await new Promise((resolve) => setTimeout(resolve, 500));
expect(events).toEqual(["assetsready", "painted"]);
expect(player.painted).toBe(true);
player._waitForAssetsReady(doc);
expect(player.painted).toBe(false);
player.remove();
});
it("fires painted right after assetsready when no loader was ever raised", async () => {
const player = await createConnectedPlayer();
const painted = vi.fn();
player.addEventListener("painted", painted);
player._waitForAssetsReady(null);
expect(player.assetsReady).toBe(true);
expect(player.painted).toBe(true);
expect(painted).toHaveBeenCalledTimes(1);
player.remove();
});
it("defers play() until a pending video settles, then plays and clears the overlay attribute", async () => {
const player = await createConnectedPlayer();
+14
View File
@@ -109,6 +109,7 @@ class HyperframesPlayer extends HTMLElement {
private _ready = false;
private _assetsReady = false;
private _painted = false;
private _pendingPlay = false;
private _assetsGeneration = 0;
private _assetsLoadingShowTimer: ReturnType<typeof setTimeout> | null = null;
@@ -532,6 +533,12 @@ class HyperframesPlayer extends HTMLElement {
return this._assetsReady;
}
/** True once assets are ready and the loading panel has finished fading out,
* so the document is what is on screen. Mirrors `painted`; resets per load. */
get painted() {
return this._painted;
}
get playbackRate() {
return clampPlaybackRate(parseFloat(this.getAttribute("playback-rate") || "1"));
}
@@ -1032,6 +1039,7 @@ class HyperframesPlayer extends HTMLElement {
private _waitForAssetsReady(doc: Document | null, runtimeAssetsReady?: boolean): void {
this._clearAssetsLoadingShowTimer();
this._assetsReady = false;
this._painted = false;
// Invalidates any earlier wait still in flight (a composition swap, or
// disconnect, mid-wait) — its eventual settle checks this and no-ops
// rather than resolving a since-superseded generation.
@@ -1099,6 +1107,11 @@ class HyperframesPlayer extends HTMLElement {
this.removeAttribute(ASSETS_LOADING_ATTR);
this.shaderLoader.hide();
this.dispatchEvent(new Event("assetsready"));
this.shaderLoader.whenHidden(() => {
if (generation !== this._assetsGeneration) return;
this._painted = true;
this.dispatchEvent(new Event("painted"));
});
if (this._pendingPlay) this.play();
}
@@ -1107,6 +1120,7 @@ class HyperframesPlayer extends HTMLElement {
private _invalidateAssetsWait(): void {
this._clearAssetsLoadingShowTimer();
this._assetsReady = false;
this._painted = false;
this._pendingPlay = false;
this._assetsGeneration++;
this.removeAttribute(ASSETS_LOADING_ATTR);
@@ -17,6 +17,7 @@ const HIDE_TRANSITION_MS = 420;
export class ShaderLoaderState {
private readonly _el: ShaderLoaderElements;
private _hideTimeout: ReturnType<typeof setTimeout> | null = null;
private _hiddenCallbacks: Array<() => void> = [];
constructor(elements: ShaderLoaderElements) {
this._el = elements;
@@ -42,12 +43,27 @@ export class ShaderLoaderState {
this._scheduleCleanup();
}
/** Runs `cb` once the panel is fully gone: now if it is not raised or
* fading, otherwise when the fade's cleanup fires. */
whenHidden(cb: () => void): void {
const cls = this._el.root.classList;
if (cls.contains("hfp-visible") || cls.contains("hfp-hiding")) this._hiddenCallbacks.push(cb);
else cb();
}
private _flushHidden(): void {
const callbacks = this._hiddenCallbacks;
this._hiddenCallbacks = [];
for (const cb of callbacks) cb();
}
reset(): void {
if (this._hideTimeout) {
clearTimeout(this._hideTimeout);
this._hideTimeout = null;
}
this._el.root.classList.remove("hfp-visible", "hfp-hiding");
this._flushHidden();
this._el.fill.style.transform = "scaleX(0)";
this._el.transitionValue.textContent = "";
this._el.frameValue.textContent = "";
@@ -136,6 +152,7 @@ export class ShaderLoaderState {
clearTimeout(this._hideTimeout);
this._hideTimeout = null;
}
this._hiddenCallbacks = [];
}
private _scheduleCleanup(): void {
@@ -143,6 +160,7 @@ export class ShaderLoaderState {
this._hideTimeout = setTimeout(() => {
this._el.root.classList.remove("hfp-hiding");
this._hideTimeout = null;
this._flushHidden();
}, HIDE_TRANSITION_MS);
}
}
@@ -172,6 +172,7 @@ export function EditorShell({
activeCompositionPath={activeCompPath}
onIframeRef={handlePreviewIframeRef}
onCompIdToSrcChange={setCompIdToSrc}
onPreviewReloadFailed={(message) => showToast(message, "error")}
onCompositionLoadingChange={setCompositionLoading}
onCompositionChange={(compPath) => {
// Sync activeCompPath when the user drills down via the timeline or
@@ -264,7 +265,7 @@ function EditorShellBody({
onDuplicateClip,
canPasteClip,
}: EditorShellBodyProps) {
const { compositionStack, updateCompositionStack } = useNLEContext();
const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
// The caption track's blocks are seek targets; CaptionTimeline took an onSeek
// prop that nothing ever passed, so clicking a block did nothing.
@@ -284,6 +285,7 @@ function EditorShellBody({
return (
<div
ref={containerRef}
// Shell canvas is a step LIGHTER than the near-black panel cards so the
// gaps between panels read as visible seams (CapCut-style).
className="flex flex-col flex-1 min-h-0 bg-panel-surface"
@@ -0,0 +1,66 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import { MotionPathOverlay } from "./MotionPathOverlay";
import { usePlayerStore } from "../../player/store/playerStore";
import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
import type { DomEditSelection } from "./domEditing";
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
vi.mock("../../contexts/DomEditContext", () => ({
useDomEditContext: () => ({ selectedGsapAnimations: [] }),
}));
vi.mock("./motionPathSelection", () => ({
selectorFor: () => "#box",
editableAnimationId: () => null,
}));
vi.mock("./useMotionPathData", async (importOriginal) => ({
...(await importOriginal<typeof import("./useMotionPathData")>()),
useMotionPathData: () => ({
rect: null,
geometry: null,
geometryResolved: true,
visibleInPreview: true,
home: null,
pScale: 1,
}),
}));
it("re-evaluates set-destination availability when a reload swaps the live iframe", () => {
const host = document.createElement("div");
const before = document.createElement("iframe");
const after = document.createElement("iframe");
document.body.append(host, before, after);
Reflect.set(after.contentWindow as object, "MotionPathPlugin", {});
const ref = { current: before };
const root = createRoot(host);
const selection = { element: document.createElement("div") } as unknown as DomEditSelection;
try {
act(() => usePreviewIframeStore.getState().setIframe(before));
act(() =>
root.render(
<MotionPathOverlay
iframeRef={ref}
selection={selection}
compositionSize={{ width: 1920, height: 1080 }}
isPlaying={false}
/>,
),
);
expect(usePlayerStore.getState().motionPathCreateAvailable).toBe(false);
act(() => {
ref.current = after;
usePreviewIframeStore.getState().setIframe(after);
});
expect(usePlayerStore.getState().motionPathCreateAvailable).toBe(true);
} finally {
act(() => root.unmount());
usePreviewIframeStore.setState({ iframe: null });
host.remove();
before.remove();
after.remove();
}
});
@@ -3,6 +3,7 @@ import { memo, useEffect, useRef, useState, type RefObject } from "react";
import type { DomEditSelection } from "./domEditing";
import { useDomEditContext } from "../../contexts/DomEditContext";
import { usePlayerStore } from "../../player/store/playerStore";
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
import { nearestPointOnPath, type MotionNodeRef } from "./motionPathGeometry";
@@ -141,7 +142,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
// No one-element selector means the path could only be authored onto the
// element's class siblings, so the toolbar toggle stays hidden instead of
// arming a press that the effect below would silently drop.
const canCreate = createMode && !!createSelector && hasMotionPathPlugin(iframeRef.current);
const livePreviewIframe = useLivePreviewIframe();
const canCreate = createMode && !!createSelector && hasMotionPathPlugin(livePreviewIframe);
// Publish whether the selected element can take a path so the preview toolbar
// shows its "set destination" toggle. Drops to false when this overlay unmounts
@@ -2,6 +2,7 @@ import { buildProjectApiPath } from "../../utils/projectRouting";
import { useContext, useState, useCallback, useRef, useEffect, type ReactNode } from "react";
import { useTimelinePlayer, usePlayerStore } from "../../player";
import type { TimelineElement } from "../../player";
import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
import type { CompositionLevel } from "./CompositionBreadcrumb";
import { useCompositionStack } from "./useCompositionStack";
import { setCompositionSourceMap } from "../editor/domEditingDom";
@@ -21,6 +22,14 @@ export interface NLEContextValue {
seek: (time: number, options?: { keepPlaying?: boolean }) => boolean;
refreshPlayer: () => void;
onIframeLoad: () => void;
// The hidden reload iframe NLEPreview renders next to the live one during a full reload.
previewSlots: PreviewIframeSlot[];
onShadowIframeLoad: (gen: number) => void;
onShadowReadyChange: (gen: number, ready: boolean) => void;
onShadowError: (gen: number, message: string) => void;
setShadowIframeNode: (node: HTMLIFrameElement | null) => void;
resetPreviewSlots: () => void;
containerRef: React.MutableRefObject<HTMLDivElement | null>;
// composition stack (from useCompositionStack)
compositionStack: CompositionLevel[];
updateCompositionStack: React.Dispatch<React.SetStateAction<CompositionLevel[]>>;
@@ -55,6 +64,8 @@ export interface NLEProviderProps {
onCompositionChange?: (compositionPath: string | null) => void;
onCompIdToSrcChange?: (map: Map<string, string>) => void;
onCompositionLoadingChange?: (loading: boolean) => void;
/** A preview reload was abandoned; the previous preview is still showing. */
onPreviewReloadFailed?: (message: string) => void;
children: ReactNode;
}
@@ -66,15 +77,27 @@ export function NLEProvider({
onCompositionChange,
onCompIdToSrcChange,
onCompositionLoadingChange,
onPreviewReloadFailed,
children,
}: NLEProviderProps) {
const shadowPromotedRef = useRef<() => void>(() => {});
const containerRef = useRef<HTMLDivElement>(null);
const {
iframeRef,
togglePlay,
seek,
onIframeLoad: baseOnIframeLoad,
refreshPlayer,
} = useTimelinePlayer();
previewSlots,
onShadowIframeLoad,
onShadowReadyChange,
onShadowError,
setShadowIframeNode,
resetPreviewSlots,
} = useTimelinePlayer({
onShadowPromoted: () => shadowPromotedRef.current(),
onPreviewReloadFailed,
});
// Reset timeline state when the project changes. Done in an effect, not during
// render: reset() updates the player store, and updating another store/component
@@ -107,14 +130,20 @@ export function NLEProvider({
refreshPlayer();
}, [refreshKey, refreshPlayer]);
const onIframeLoad = useCallback(() => {
baseOnIframeLoad();
// Steps that follow every load of the live iframe, including a reload promoted in place.
const afterLiveIframeLoad = useCallback(() => {
// Pre-load + register MotionPathPlugin once so adding a motion path in the
// studio doesn't take the async plugin-load flash path on the first soft
// reload (the comp may not ship the plugin until it actually uses one).
ensureMotionPathPluginLoaded(iframeRef.current);
onIframeRef?.(iframeRef.current);
}, [baseOnIframeLoad, iframeRef, onIframeRef]);
}, [iframeRef, onIframeRef]);
shadowPromotedRef.current = afterLiveIframeLoad;
const onIframeLoad = useCallback(() => {
baseOnIframeLoad();
afterLiveIframeLoad();
}, [baseOnIframeLoad, afterLiveIframeLoad]);
const {
compositionStack,
@@ -269,10 +298,17 @@ export function NLEProvider({
const value: NLEContextValue = {
projectId,
iframeRef,
containerRef,
togglePlay,
seek,
refreshPlayer,
onIframeLoad,
previewSlots,
onShadowIframeLoad,
onShadowReadyChange,
onShadowError,
setShadowIframeNode,
resetPreviewSlots,
compositionStack,
updateCompositionStack,
handleNavigateComposition,
@@ -3,10 +3,13 @@
import React, { act, createRef } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useTimelinePlayer } from "../../player/hooks/useTimelinePlayer";
import { NLEPreview, getPreviewPlayerKey, resolvePreviewStageSize } from "./NLEPreview";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const playerMounts: string[] = [];
vi.mock("../../player", async () => {
const React = await import("react");
@@ -14,6 +17,7 @@ vi.mock("../../player", async () => {
Player: React.forwardRef(function MockPlayer(
props: {
onLoad?: () => void;
suppressLoadingOverlay?: boolean;
style?: React.CSSProperties;
},
ref: React.ForwardedRef<HTMLIFrameElement>,
@@ -21,6 +25,7 @@ vi.mock("../../player", async () => {
React.useEffect(() => {
props.onLoad?.();
}, [props]);
React.useState(() => playerMounts.push(props.suppressLoadingOverlay ? "shadow" : "live"));
return React.createElement("div", {
ref: ref as React.ForwardedRef<HTMLDivElement>,
@@ -70,7 +75,11 @@ function setRect(node: Element, rect: { width: number; height: number }) {
});
}
function renderPreview() {
function renderPreview(
previewSlots: Array<{ gen: number; role: "live" | "shadow"; url?: string }> = [
{ gen: 0, role: "live" },
],
) {
resizeCallbacks = [];
const host = document.createElement("div");
document.body.append(host);
@@ -83,6 +92,12 @@ function renderPreview() {
projectId: "timeline-edit-playground",
iframeRef,
onIframeLoad: () => {},
previewSlots,
onShadowIframeLoad: () => {},
onShadowReadyChange: () => {},
onShadowError: () => {},
setShadowIframeNode: () => {},
resetPreviewSlots: () => {},
}),
);
});
@@ -227,4 +242,46 @@ describe("NLEPreview", () => {
expect(view.stage.style.transform).toContain("translate3d(30px, -24px, 0)");
view.cleanup();
});
it("clips a shadow reload so its own loading overlay cannot paint over the live frame", () => {
const view = renderPreview([
{ gen: 0, role: "live" },
{ gen: 1, role: "shadow", url: "/api/projects/p/preview?_t=1" },
]);
const players = [...view.stage.querySelectorAll<HTMLElement>('[data-testid="mock-player"]')];
expect(players).toHaveLength(2);
expect(players[0].style.clipPath).toBe("");
expect(players[1].style.clipPath).toBe("inset(100%)");
expect(players[1].style.visibility).toBe("hidden");
view.cleanup();
});
it("mounts the live player once when the composition switches", () => {
playerMounts.length = 0;
const Harness = ({ projectId }: { projectId: string }) => {
const api = useTimelinePlayer();
return React.createElement(NLEPreview, {
projectId,
iframeRef: api.iframeRef,
onIframeLoad: api.onIframeLoad,
previewSlots: api.previewSlots,
onShadowIframeLoad: api.onShadowIframeLoad,
onShadowReadyChange: api.onShadowReadyChange,
onShadowError: api.onShadowError,
setShadowIframeNode: api.setShadowIframeNode,
resetPreviewSlots: api.resetPreviewSlots,
});
};
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => root.render(React.createElement(Harness, { projectId: "a" })));
expect(playerMounts).toEqual(["live"]);
act(() => root.render(React.createElement(Harness, { projectId: "b" })));
expect(playerMounts).toEqual(["live", "live"]);
act(() => root.unmount());
host.remove();
});
});
@@ -1,5 +1,10 @@
import {
readPreviewCompositionSize,
type PreviewCompositionSize,
} from "../../utils/previewCompositionSize";
import { memo, useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { Player } from "../../player";
import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
import {
DEFAULT_PREVIEW_ZOOM,
canStartPreviewPan,
@@ -17,6 +22,12 @@ interface NLEPreviewProps {
projectId: string;
iframeRef: RefObject<HTMLIFrameElement | null>;
onIframeLoad: () => void;
previewSlots: PreviewIframeSlot[];
onShadowIframeLoad: (gen: number) => void;
onShadowReadyChange: (gen: number, ready: boolean) => void;
onShadowError: (gen: number, message: string) => void;
setShadowIframeNode: (node: HTMLIFrameElement | null) => void;
resetPreviewSlots: () => void;
onCompositionLoadingChange?: (loading: boolean) => void;
portrait?: boolean;
directUrl?: string;
@@ -40,10 +51,15 @@ const ZOOM_HUD_TIMEOUT_MS = 1200;
const ZOOM_SETTLE_MS = 200;
const PREVIEW_STAGE_INSET_PX = 16;
interface PreviewCompositionSize {
width: number;
height: number;
}
// clip-path as well as visibility: the player's loading overlay sets its own
// visibility:visible and would otherwise paint over the live frame.
const SHADOW_IFRAME_STYLE: React.CSSProperties = {
position: "absolute",
inset: 0,
visibility: "hidden",
clipPath: "inset(100%)",
pointerEvents: "none",
};
function isPreviewAtFit(state: PreviewZoomState): boolean {
return (
@@ -64,27 +80,6 @@ function loadInitialZoom(): PreviewZoomState {
: DEFAULT_PREVIEW_ZOOM;
}
// fallow-ignore-next-line complexity
function readPreviewCompositionSize(
iframe: HTMLIFrameElement | null,
): PreviewCompositionSize | null {
try {
const doc = iframe?.contentDocument;
const root =
doc?.querySelector("[data-composition-id][data-width][data-height]") ??
doc?.querySelector("[data-width][data-height]");
if (!root) return null;
const width = Number.parseInt(root.getAttribute("data-width") ?? "", 10);
const height = Number.parseInt(root.getAttribute("data-height") ?? "", 10);
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
return null;
}
return { width, height };
} catch {
return null;
}
}
export function resolvePreviewStageSize(
viewportWidth: number,
viewportHeight: number,
@@ -122,6 +117,12 @@ export const NLEPreview = memo(function NLEPreview({
projectId,
iframeRef,
onIframeLoad,
previewSlots,
onShadowIframeLoad,
onShadowReadyChange,
onShadowError,
setShadowIframeNode,
resetPreviewSlots,
onCompositionLoadingChange,
portrait,
directUrl,
@@ -136,6 +137,16 @@ export const NLEPreview = memo(function NLEPreview({
useEffect(() => {
onStageRef?.(stageRef);
}, [onStageRef]);
// Composition switch: drop any in-flight shadow reload (skipped on first mount).
const previousActiveKeyRef = useRef(activeKey);
useEffect(() => {
if (previousActiveKeyRef.current === activeKey) return;
previousActiveKeyRef.current = activeKey;
resetPreviewSlots();
}, [activeKey, resetPreviewSlots]);
const liveGenRef = useRef<number | null>(null);
const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
@@ -299,6 +310,18 @@ export const NLEPreview = memo(function NLEPreview({
}
}, [writeTransform]);
// A promotion does not re-fire Player.onLoad: re-sync the local iframe ref, size and zoom.
useEffect(() => {
const live = previewSlots.find((slot) => slot.role === "live");
if (!live || live.gen === liveGenRef.current) return;
const isPromotion = liveGenRef.current !== null;
liveGenRef.current = live.gen;
if (!isPromotion) return;
previewIframeRef.current = iframeRef.current;
updateCompositionSizeFromPreview();
applyInitialZoom();
}, [previewSlots, iframeRef, updateCompositionSizeFromPreview, applyInitialZoom]);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
@@ -469,25 +492,42 @@ export const NLEPreview = memo(function NLEPreview({
style={{ position: "absolute", inset: 0, zIndex: 0 }}
/>
)}
<Player
key={activeKey}
ref={setPreviewIframeRef}
projectId={directUrl ? undefined : projectId}
directUrl={directUrl}
onLoad={() => {
updateCompositionSizeFromPreview();
onIframeLoad();
applyInitialZoom();
}}
onCompositionLoadingChange={onCompositionLoadingChange}
portrait={portrait}
suppressLoadingOverlay={suppressLoadingOverlay}
style={
directUrl?.includes("/components/")
? { position: "absolute", inset: 0, zIndex: 1 }
: undefined
}
/>
{previewSlots.map((slot) =>
slot.role === "live" ? (
<Player
key={`${activeKey}-${slot.gen}`}
ref={setPreviewIframeRef}
projectId={directUrl ? undefined : projectId}
directUrl={directUrl}
onLoad={() => {
updateCompositionSizeFromPreview();
onIframeLoad();
applyInitialZoom();
}}
onCompositionLoadingChange={onCompositionLoadingChange}
portrait={portrait}
suppressLoadingOverlay={suppressLoadingOverlay}
style={
directUrl?.includes("/components/")
? { position: "absolute", inset: 0, zIndex: 1 }
: undefined
}
/>
) : (
// Loads hidden behind the live slot until promoted.
<Player
key={`${activeKey}-${slot.gen}`}
ref={setShadowIframeNode}
directUrl={slot.url}
onLoad={() => onShadowIframeLoad(slot.gen)}
onReadyToShowChange={(ready) => onShadowReadyChange(slot.gen, ready)}
onPreviewError={(message) => onShadowError(slot.gen, message)}
portrait={portrait}
suppressLoadingOverlay
style={SHADOW_IFRAME_STYLE}
/>
),
)}
</div>
</div>
<div
@@ -60,6 +60,12 @@ export function PreviewPane({
togglePlay,
seek,
onIframeLoad,
previewSlots,
onShadowIframeLoad,
onShadowReadyChange,
onShadowError,
setShadowIframeNode,
resetPreviewSlots,
compositionStack,
handleNavigateComposition,
setCompositionLoading,
@@ -131,6 +137,12 @@ export function PreviewPane({
projectId={projectId}
iframeRef={iframeRef}
onIframeLoad={onIframeLoad}
previewSlots={previewSlots}
onShadowIframeLoad={onShadowIframeLoad}
onShadowReadyChange={onShadowReadyChange}
onShadowError={onShadowError}
setShadowIframeNode={setShadowIframeNode}
resetPreviewSlots={resetPreviewSlots}
onCompositionLoadingChange={setCompositionLoading}
portrait={portrait}
directUrl={directUrl}
@@ -3,6 +3,7 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it } from "vitest";
import { useCompositionDimensions } from "./useCompositionDimensions";
import { usePreviewIframeStore } from "../player/store/previewIframeStore";
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
@@ -45,3 +46,40 @@ it("accepts only the current preview frame and rejects foreign or missing sender
foreign.remove();
}
});
it("re-reads the size from the promoted iframe's document", () => {
const host = document.createElement("div");
const a = document.createElement("iframe");
const b = document.createElement("iframe");
document.body.append(host, a, b);
const ref = { current: a };
const root = createRoot(host);
const setRoot = (frame: HTMLIFrameElement, width: number, height: number) => {
const el = frame.contentDocument!.createElement("div");
el.setAttribute("data-composition-id", "main");
el.setAttribute("data-width", String(width));
el.setAttribute("data-height", String(height));
frame.contentDocument!.body.append(el);
};
setRoot(a, 1920, 1080);
setRoot(b, 1080, 1920);
function Harness() {
return JSON.stringify(useCompositionDimensions(ref));
}
try {
act(() => usePreviewIframeStore.getState().setIframe(a));
act(() => root.render(<Harness />));
expect(host.textContent).toBe('{"width":1920,"height":1080}');
act(() => {
ref.current = b;
usePreviewIframeStore.getState().setIframe(b);
});
expect(host.textContent).toBe('{"width":1080,"height":1920}');
} finally {
act(() => root.unmount());
usePreviewIframeStore.setState({ iframe: null });
host.remove();
a.remove();
b.remove();
}
});
@@ -1,4 +1,6 @@
import { useState, type RefObject } from "react";
import { useEffect, useState, type RefObject } from "react";
import { useLivePreviewIframe } from "../player/store/previewIframeStore";
import { readPreviewCompositionSize } from "../utils/previewCompositionSize";
import { useMountEffect } from "./useMountEffect";
import type { CompositionDimensions } from "../components/renders/RenderQueue";
import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
@@ -34,6 +36,20 @@ export function useCompositionDimensions(iframeRef: RefObject<HTMLIFrameElement
null,
);
const livePreviewIframe = useLivePreviewIframe();
// The runtime posts stage-size once per document, so a promoted reload's message was sent
// before this listener could accept it. The size is authored on the document: read it there.
useEffect(() => {
const dimensions = readPreviewCompositionSize(livePreviewIframe);
if (!dimensions) return;
setCompositionDimensions((prev) =>
prev && prev.width === dimensions.width && prev.height === dimensions.height
? prev
: dimensions,
);
}, [livePreviewIframe]);
useMountEffect(() => {
const handleMessage = (e: MessageEvent) => {
if (!e.source || e.source !== iframeRef.current?.contentWindow) return;
@@ -54,26 +54,37 @@ afterEach(() => {
document.body.innerHTML = "";
});
async function mountPlayer() {
type PlayerProps = Parameters<typeof Player>[0];
async function mountPlayer(props: Partial<PlayerProps> = {}) {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
await act(async () => {
root?.render(
createElement(Player, {
directUrl: "/api/projects/demo/preview",
onLoad: vi.fn(),
suppressLoadingOverlay: true,
}),
);
await Promise.resolve();
});
const render = () =>
act(async () => {
root?.render(
createElement(Player, {
directUrl: "/api/projects/demo/preview",
onLoad: vi.fn(),
suppressLoadingOverlay: true,
...props,
}),
);
await Promise.resolve();
});
await render();
const player = host.querySelector<TestHyperframesPlayer>("hyperframes-player");
if (!player) throw new Error("player did not mount");
return { host, player };
const rerender = (next: Partial<PlayerProps>) => {
Object.assign(props, next);
return render();
};
return { host, player, rerender };
}
const twoFrames = () => act(async () => void (await new Promise((r) => setTimeout(r, 80))));
function createAudioIframe() {
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
@@ -123,6 +134,7 @@ describe("preview errors", () => {
"player:click",
"player:shadertransitionstate",
"player:ready",
"player:painted",
"player:error",
]) {
expect(lifecycleLog.indexOf(listener)).toBeGreaterThan(-1);
@@ -155,6 +167,101 @@ describe("preview errors", () => {
});
});
describe("callbacks after the player is already mounted", () => {
it("runs the latest onLoad on a later load, not the one captured at mount", async () => {
const first = vi.fn();
const second = vi.fn();
const { player, rerender } = await mountPlayer({ onLoad: first });
await rerender({ onLoad: second });
act(
() => void (player as TestHyperframesPlayer).iframeElement.dispatchEvent(new Event("load")),
);
expect(second).toHaveBeenCalledTimes(1);
expect(first).not.toHaveBeenCalled();
});
it("reports the preview error cause", async () => {
const onPreviewError = vi.fn();
const { player } = await mountPlayer({ onPreviewError });
act(() => {
player.dispatchEvent(new CustomEvent("error", { detail: { message: "boom" } }));
});
expect(onPreviewError).toHaveBeenCalledWith("boom");
});
});
describe("ready to show", () => {
const loadAndReady = (player: TestHyperframesPlayer) =>
act(() => {
player.iframeElement.dispatchEvent(new Event("load"));
player.dispatchEvent(new Event("ready"));
});
const shaderState = (player: TestHyperframesPlayer, loading: boolean) =>
act(() => {
player.dispatchEvent(
new CustomEvent("shadertransitionstate", {
detail: { state: { loading, ready: !loading } },
}),
);
});
const painted = (player: TestHyperframesPlayer) =>
act(() => void player.dispatchEvent(new Event("painted")));
it("promotes only once the player reports painted, not at ready or assetsready", async () => {
const onReadyToShowChange = vi.fn();
const { player } = await mountPlayer({ onReadyToShowChange });
const el = player as TestHyperframesPlayer;
loadAndReady(el);
await twoFrames();
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
act(() => void el.dispatchEvent(new Event("assetsready")));
await twoFrames();
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
painted(el);
await twoFrames();
expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
act(() => void el.iframeElement.dispatchEvent(new Event("load")));
expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
});
it("holds while the shader transition loader is up and fires once it clears", async () => {
const onReadyToShowChange = vi.fn();
const { player } = await mountPlayer({ onReadyToShowChange });
const el = player as TestHyperframesPlayer;
shaderState(el, true);
loadAndReady(el);
shaderState(el, true);
await twoFrames();
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
shaderState(el, false);
painted(el);
await twoFrames();
expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
shaderState(el, true);
expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
});
it("does not fire for a document that failed to load", async () => {
const onReadyToShowChange = vi.fn();
const { player } = await mountPlayer({ onReadyToShowChange });
act(() => {
player.dispatchEvent(new CustomEvent("error", { detail: { message: "boom" } }));
});
await twoFrames();
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
});
});
describe("composition loading overlay", () => {
it("shows while the composition is loading", () => {
expect(shouldShowCompositionLoadingOverlay(true)).toBe(true);
@@ -26,6 +26,9 @@ interface PlayerProps {
projectId?: string;
directUrl?: string;
onLoad: () => void;
/** Fires once the loaded document is painted and every loader (shader, assets) has cleared. */
onReadyToShowChange?: (ready: boolean) => void;
onPreviewError?: (message: string) => void;
onCompositionLoadingChange?: (loading: boolean) => void;
portrait?: boolean;
style?: React.CSSProperties;
@@ -134,6 +137,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
projectId,
directUrl,
onLoad,
onReadyToShowChange,
onPreviewError,
onCompositionLoadingChange,
portrait,
style,
@@ -147,12 +152,22 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retryPreviewRef = useRef<(() => void) | null>(null);
const retryCountRef = useRef(0);
// Read at call time: this element outlives its first props (a shadow preview is
// promoted in place), so mount-time closures would go stale.
const onLoadRef = useRef(onLoad);
onLoadRef.current = onLoad;
const onReadyToShowChangeRef = useRef(onReadyToShowChange);
onReadyToShowChangeRef.current = onReadyToShowChange;
const onPreviewErrorRef = useRef(onPreviewError);
onPreviewErrorRef.current = onPreviewError;
const [loaded, setLoaded] = useState(false);
const [assetsLoading, setAssetsLoading] = useState(false);
const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
const [assetWaitLong, setAssetWaitLong] = useState(false);
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
const [compositionLoading, setCompositionLoading] = useState(true);
const [painted, setPainted] = useState(false);
const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
const [previewError, setPreviewError] = useState<string | null>(null);
@@ -207,15 +222,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
setPreviewError(null);
setCompositionLoading(false);
};
const handlePainted = () => setPainted(true);
const handleError = (event: Event) => {
setPreviewError(readPreviewErrorMessage(event));
const message = readPreviewErrorMessage(event);
onPreviewErrorRef.current?.(message);
setPreviewError(message);
setCompositionLoading(false);
};
const handleLoad = () => {
loadCountRef.current++;
setLoaded(true);
setPreviewError(null);
setShaderTransitionLoading(false);
setCompositionLoading(true);
setPainted(false);
// Reveal animation on reload (hot-reload, composition switch)
if (loadCountRef.current > 1) {
container.classList.remove("preview-revealing");
@@ -224,7 +244,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
const onEnd = () => container.classList.remove("preview-revealing");
container.addEventListener("animationend", onEnd, { once: true });
}
onLoad();
onLoadRef.current();
// Show a loading overlay until every `<video>`/`<audio>` and Lottie
// asset is ready. Without this users can click play before audio has
@@ -274,6 +294,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
player.addEventListener("click", preventToggle, { capture: true });
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
player.addEventListener("ready", handleReady);
player.addEventListener("painted", handlePainted);
player.addEventListener("error", handleError);
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
@@ -313,6 +334,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
player.removeEventListener("click", preventToggle, { capture: true });
player.removeEventListener("shadertransitionstate", handleShaderTransitionState);
player.removeEventListener("ready", handleReady);
player.removeEventListener("painted", handlePainted);
player.removeEventListener("error", handleError);
if (assetPollRef.current) clearInterval(assetPollRef.current);
assetPollRef.current = null;
@@ -394,6 +416,29 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
setAssetsLoading(false);
};
const readyToShow =
loaded &&
painted &&
!compositionLoading &&
!shaderTransitionLoading &&
!assetsLoading &&
!previewError;
// `painted` means the player's own loader has finished fading; two frames of grace on top.
useEffect(() => {
if (!readyToShow) {
onReadyToShowChangeRef.current?.(false);
return;
}
let second = 0;
const first = requestAnimationFrame(() => {
second = requestAnimationFrame(() => onReadyToShowChangeRef.current?.(true));
});
return () => {
cancelAnimationFrame(first);
cancelAnimationFrame(second);
};
}, [readyToShow]);
const showCompositionOverlay =
!suppressLoadingOverlay &&
!compositionOverlayDeferred &&
@@ -3,7 +3,7 @@
import React, { act, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { vi } from "vitest";
import { useTimelinePlayer } from "./useTimelinePlayer";
import { useTimelinePlayer, type UseTimelinePlayerOptions } from "./useTimelinePlayer";
import { usePlayerStore } from "../store/playerStore";
export type TimelinePlayerApi = ReturnType<typeof useTimelinePlayer>;
@@ -13,29 +13,52 @@ export function resetPlayerStore() {
usePlayerStore.setState({ requestedSeekTime: null });
}
function TimelinePlayerHarness({ onValue }: { onValue: (value: TimelinePlayerApi) => void }) {
const value = useTimelinePlayer();
function TimelinePlayerHarness({
onValue,
options,
}: {
onValue: (value: TimelinePlayerApi) => void;
options?: UseTimelinePlayerOptions;
}) {
const value = useTimelinePlayer(options);
useEffect(() => {
onValue(value);
}, [onValue, value]);
return null;
}
export function renderTimelinePlayerHarness() {
let api: TimelinePlayerApi | null = null;
/** `api` is the first render's snapshot; `getApi()` returns the latest render's value. */
export function renderTimelinePlayerHarness(options?: UseTimelinePlayerOptions) {
let latest: TimelinePlayerApi | null = null;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }));
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (latest = value), options }),
);
});
if (!api) throw new Error("useTimelinePlayer did not mount");
return { api: api as TimelinePlayerApi, root };
if (!latest) throw new Error("useTimelinePlayer did not mount");
const rerender = (next?: UseTimelinePlayerOptions) =>
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, {
onValue: (value) => (latest = value),
options: next,
}),
);
});
return {
api: latest as TimelinePlayerApi,
getApi: () => latest as TimelinePlayerApi,
root,
rerender,
};
}
function makeFakeIframe(iframeWindow: Record<string, unknown>): HTMLIFrameElement {
export function makeFakeIframe(iframeWindow: Record<string, unknown>): HTMLIFrameElement {
const iframe = document.createElement("iframe");
Object.defineProperty(iframe, "contentWindow", { value: iframeWindow, configurable: true });
Object.defineProperty(iframe, "contentDocument", {
@@ -53,7 +76,7 @@ export function attachIframeWindow(api: TimelinePlayerApi, iframeWindow: Record<
});
}
function makeAdapterWindow(
export function makeAdapterWindow(
options: {
postMessage?: (message: unknown, targetOrigin: string) => void;
timelines?: Record<string, unknown>;
@@ -0,0 +1,183 @@
// Loads a full-reload edit in a hidden shadow iframe and promotes it once painted,
// so the live iframe never shows a blank frame.
import { useCallback, useRef, useState } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { logReload } from "../../utils/reloadDebug";
import {
useTimelineSyncCallbacks,
planShadowReload,
planShadowPromotion,
planShadowDiscard,
type PreviewIframeSlot,
type UseTimelineSyncCallbacksParams,
} from "./useTimelineSyncCallbacks";
import type { PlaybackAdapter } from "../lib/playbackTypes";
// The single wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
// leaves about 6.5s for the document load and runtime boot. Nothing shorter may fail the swap.
export const SHADOW_READY_TIMEOUT_MS = 15_000;
type UseShadowPreviewReloadParams = Omit<
UseTimelineSyncCallbacksParams,
"probeIntervalRef" | "onAdapterReady" | "getAdapter" | "isCurrent" | "onLoadGiveUp"
> & {
getAdapter: (overrideIframe?: HTMLIFrameElement | null) => PlaybackAdapter | null;
/** Runs right after a shadow becomes the live iframe (iframeRef already points at it). */
onPromoted?: () => void;
/** A shadow that never became ready was dropped; the live preview is unchanged. */
onReloadFailed?: (message: string) => void;
};
export function useShadowPreviewReload({
iframeRef,
getAdapter,
pendingSeekRef,
isRefreshingRef,
syncTimelineElements,
setDuration,
setCurrentTime,
requestTimelineReady,
setIsPlaying,
attachIframeShortcutListeners,
applyPreviewAudioState,
onPromoted,
onReloadFailed,
}: UseShadowPreviewReloadParams) {
const shadowIframeRef = useRef<HTMLIFrameElement | null>(null);
const shadowProbeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
// Invalidation counter, separate from the live slot's key: bumping it never remounts the live Player.
const shadowGenRef = useRef(0);
const pendingCommitRef = useRef<{ gen: number; commit: () => void } | null>(null);
const visuallyReadyGenRef = useRef<number | null>(null);
const onPromotedRef = useRef(onPromoted);
onPromotedRef.current = onPromoted;
const onReloadFailedRef = useRef(onReloadFailed);
onReloadFailedRef.current = onReloadFailed;
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const cancelPendingLoadRef = useRef<() => void>(() => {});
const [previewSlots, setPreviewSlots] = useState<PreviewIframeSlot[]>([{ gen: 0, role: "live" }]);
const stopPendingShadow = useCallback(() => {
clearTimeout(readyTimerRef.current);
cancelPendingLoadRef.current();
pendingCommitRef.current = null;
visuallyReadyGenRef.current = null;
}, []);
const failShadow = useCallback(
(gen: number, cause: string) => {
if (gen !== shadowGenRef.current) return;
shadowGenRef.current += 1;
stopPendingShadow();
shadowIframeRef.current = null;
isRefreshingRef.current = false;
pendingSeekRef.current = null;
setPreviewSlots(planShadowDiscard);
const message = `The preview did not reload (${cause}). The previous preview is still showing.`;
logReload("shadow-failed", { cause });
console.error(`[studio] ${message}`);
onReloadFailedRef.current?.(message);
},
[stopPendingShadow, isRefreshingRef, pendingSeekRef],
);
const promoteWhenReady = useCallback(
(gen: number) => {
const shadow = shadowIframeRef.current;
const pending = pendingCommitRef.current;
const ready = pending?.gen === gen && visuallyReadyGenRef.current === gen;
if (!shadow || !pending || !ready || gen !== shadowGenRef.current) return;
stopPendingShadow();
// The store takes the new document's timeline only now that it is the one on screen.
pending.commit();
iframeRef.current = shadow;
shadowIframeRef.current = null;
attachIframeShortcutListeners();
applyPreviewAudioState();
setPreviewSlots((prev) => planShadowPromotion(prev, gen));
onPromotedRef.current?.();
},
[stopPendingShadow, iframeRef, attachIframeShortcutListeners, applyPreviewAudioState],
);
const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
const isCurrentShadow = useCallback((gen?: number) => gen === shadowGenRef.current, []);
const markAdapterReady = useCallback(
(_iframe: HTMLIFrameElement | null, gen: number | undefined, commit: () => void) => {
if (gen == null || gen !== shadowGenRef.current) return;
pendingCommitRef.current = { gen, commit };
promoteWhenReady(gen);
},
[promoteWhenReady],
);
const { onIframeLoad: onShadowIframeLoad, cancelPendingLoad } = useTimelineSyncCallbacks({
iframeRef: shadowIframeRef,
probeIntervalRef: shadowProbeIntervalRef,
pendingSeekRef,
isRefreshingRef,
getAdapter: getShadowAdapter,
syncTimelineElements,
setDuration,
setCurrentTime,
requestTimelineReady,
setIsPlaying,
// A hidden shadow gets neither: shortcuts and audio state apply once it is promoted.
attachIframeShortcutListeners: () => {},
applyPreviewAudioState: () => {},
onAdapterReady: markAdapterReady,
isCurrent: isCurrentShadow,
});
cancelPendingLoadRef.current = cancelPendingLoad;
// The Player reports whether loaders (shader transition, assets) are cleared right now.
const onShadowReadyChange = useCallback(
(gen: number, ready: boolean) => {
if (gen !== shadowGenRef.current) return;
visuallyReadyGenRef.current = ready ? gen : null;
if (ready) promoteWhenReady(gen);
},
[promoteWhenReady],
);
const setShadowIframeNode = useCallback((node: HTMLIFrameElement | null) => {
shadowIframeRef.current = node;
}, []);
const beginShadowReload = useCallback(
(url: string) => {
shadowGenRef.current += 1;
const gen = shadowGenRef.current;
stopPendingShadow();
readyTimerRef.current = setTimeout(
() => failShadow(gen, "it took too long to load"),
SHADOW_READY_TIMEOUT_MS,
);
setPreviewSlots((prev) => planShadowReload(prev, gen, url));
},
[stopPendingShadow, failShadow],
);
// Composition switch (not an edit reload): drop any in-flight shadow.
const resetPreviewSlots = useCallback(() => {
shadowGenRef.current += 1;
stopPendingShadow();
shadowIframeRef.current = null;
isRefreshingRef.current = false;
pendingSeekRef.current = null;
setPreviewSlots(planShadowDiscard);
}, [stopPendingShadow, isRefreshingRef, pendingSeekRef]);
useMountEffect(() => stopPendingShadow);
return {
previewSlots,
onShadowIframeLoad,
onShadowReadyChange,
onShadowError: failShadow,
setShadowIframeNode,
beginShadowReload,
resetPreviewSlots,
};
}
@@ -0,0 +1,457 @@
// @vitest-environment happy-dom
// A full-reload edit must never hide the live iframe until the shadow has painted.
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SHADOW_READY_TIMEOUT_MS } from "./useShadowPreviewReload";
import { usePlayerStore } from "../store/playerStore";
import { NLEProvider, useNLEContext, type NLEContextValue } from "../../components/nle/NLEContext";
import {
makeAdapterWindow,
makeFakeIframe,
renderTimelinePlayerHarness,
resetPlayerStore,
} from "./timelinePlayerTestHarness";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("../../utils/gsapSoftReload", async (importOriginal) => ({
...(await importOriginal<typeof import("../../utils/gsapSoftReload")>()),
ensureMotionPathPluginLoaded: vi.fn(),
}));
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
document.body.innerHTML = "";
resetPlayerStore();
});
function makeLiveIframe(): HTMLIFrameElement {
const iframe = makeFakeIframe(makeAdapterWindow().win);
iframe.src = "http://localhost/api/projects/demo/preview";
return iframe;
}
function makeShadowWithSpies() {
const { adapter, win } = makeAdapterWindow();
adapter.seek = vi.fn(adapter.seek);
const iframe = makeFakeIframe(win);
iframe.src = "http://localhost/api/projects/demo/preview?_t=1";
return { adapter, iframe };
}
describe("useTimelinePlayer shadow reload", () => {
it("never hides or removes the live iframe before the shadow signals it has painted", () => {
const { getApi, root } = renderTimelinePlayerHarness();
const liveIframe = makeLiveIframe();
act(() => {
getApi().iframeRef.current = liveIframe;
getApi().onIframeLoad();
});
expect(getApi().previewSlots).toEqual([{ gen: 0, role: "live" }]);
expect(liveIframe.style.visibility).toBe("");
// A lane-move drop persists, then bumps refreshKey -> refreshPlayer().
act(() => {
getApi().refreshPlayer();
});
// The live iframe must be untouched: still the ref target, still visible,
// still in the slot list as "live". A shadow slot appears alongside it —
// this is the queued background reload, not a swap.
expect(getApi().iframeRef.current).toBe(liveIframe);
expect(liveIframe.style.visibility).toBe("");
expect(getApi().previewSlots).toHaveLength(2);
const liveSlot = getApi().previewSlots.find((s) => s.role === "live");
const shadowSlot = getApi().previewSlots.find((s) => s.role === "shadow");
expect(liveSlot).toEqual({ gen: 0, role: "live" });
expect(shadowSlot?.role).toBe("shadow");
expect(shadowSlot?.url).toBeTruthy();
// The shadow iframe loads and its restore-seek finds a ready adapter —
// the readiness signal this whole mechanism hangs off.
const shadowIframe = makeLiveIframe();
act(() => {
getApi().setShadowIframeNode(shadowIframe);
});
// Still untouched right up to the instant before the ready signal.
expect(getApi().iframeRef.current).toBe(liveIframe);
expect(liveIframe.style.visibility).toBe("");
act(() => {
getApi().onShadowIframeLoad(shadowSlot!.gen);
getApi().onShadowReadyChange(shadowSlot!.gen, true);
});
// Promotion: a single atomic swap. Exactly one live slot, pointing at the
// shadow's iframe; the old live slot is gone, not merely hidden.
expect(getApi().previewSlots).toEqual([
{ gen: shadowSlot!.gen, role: "live", url: shadowSlot!.url },
]);
expect(getApi().iframeRef.current).toBe(shadowIframe);
unmount(root);
});
it("replaces a superseded shadow rather than accumulating extra slots (repeated lane-moves)", () => {
const { getApi, root } = renderTimelinePlayerHarness();
const liveIframe = makeLiveIframe();
act(() => {
getApi().iframeRef.current = liveIframe;
getApi().onIframeLoad();
});
act(() => {
getApi().refreshPlayer();
});
const firstShadowGen = getApi().previewSlots.find((s) => s.role === "shadow")?.gen;
expect(firstShadowGen).toBeDefined();
// The first shadow's iframe finishes loading and is fully adapter-ready
// (a real "would have painted correctly" candidate) before it is
// superseded.
const firstShadow = makeShadowWithSpies();
act(() => {
getApi().setShadowIframeNode(firstShadow.iframe);
});
// A second edit lands before the first shadow's readiness was consumed.
act(() => {
getApi().refreshPlayer();
});
// Never more than one extra (shadow) slot alive at a time.
expect(getApi().previewSlots).toHaveLength(2);
const secondShadow = getApi().previewSlots.find((s) => s.role === "shadow");
expect(secondShadow?.gen).not.toBe(firstShadowGen);
// The first shadow's readiness signal now arrives late (its own iframe is
// still referenced, fully ready). It must NOT promote — that content was
// replaced before it ever painted, and gen no longer matches the current
// pending shadow.
act(() => {
getApi().onShadowIframeLoad(firstShadowGen!);
getApi().onShadowReadyChange(firstShadowGen!, true);
});
expect(getApi().previewSlots.find((s) => s.role === "live")).toEqual({ gen: 0, role: "live" });
expect(getApi().iframeRef.current).toBe(liveIframe);
expect(firstShadow.adapter.seek).not.toHaveBeenCalled();
unmount(root);
});
});
describe("drop that removes a covered clip (reloadPreview -> refreshKey bump)", () => {
it("swaps in the reloaded document without ever hiding the live iframe", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("{}", { status: 404 })),
);
let ctx: NLEContextValue | null = null;
const Probe = () => {
ctx = useNLEContext();
return null;
};
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const render = (refreshKey: number) =>
act(async () => {
root.render(
React.createElement(
NLEProvider,
{ projectId: "demo", refreshKey },
React.createElement(Probe),
),
);
await Promise.resolve();
});
await render(0);
const live = makeLiveIframe();
await act(async () => {
ctx!.iframeRef.current = live;
ctx!.onIframeLoad();
});
// runPlacementSteps ends in exactly one reloadPreview: App bumps refreshKey.
await render(1);
expect(live.style.visibility).toBe("");
expect(ctx!.iframeRef.current).toBe(live);
const shadow = ctx!.previewSlots.find((s) => s.role === "shadow");
expect(shadow).toBeDefined();
expect(ctx!.previewSlots).toHaveLength(2);
const shadowIframe = makeLiveIframe();
await act(async () => {
ctx!.setShadowIframeNode(shadowIframe);
ctx!.onShadowIframeLoad(shadow!.gen);
ctx!.onShadowReadyChange(shadow!.gen, true);
});
expect(ctx!.previewSlots).toEqual([{ ...shadow!, role: "live" }]);
expect(ctx!.iframeRef.current).toBe(shadowIframe);
await act(async () => root.unmount());
vi.unstubAllGlobals();
});
});
describe("shadow reload readiness and failure", () => {
function beginReload(options: Parameters<typeof renderTimelinePlayerHarness>[0] = {}) {
const harness = renderTimelinePlayerHarness(options);
const { adapter, win } = makeAdapterWindow();
adapter.seek = vi.fn(adapter.seek);
const live = makeFakeIframe(win);
act(() => {
harness.getApi().iframeRef.current = live;
harness.getApi().onIframeLoad();
});
adapter.pause.mockClear();
act(() => harness.getApi().refreshPlayer());
const gen = harness.getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
return { ...harness, live, liveAdapter: adapter, gen };
}
it("pauses the live adapter when a reload starts", () => {
const { liveAdapter, root } = beginReload();
expect(liveAdapter.pause).toHaveBeenCalled();
unmount(root);
});
it("does not promote a shadow whose loader is still up, and promotes once it clears", () => {
const { getApi, live, gen, root } = beginReload();
const shadow = makeShadowWithSpies();
act(() => {
getApi().setShadowIframeNode(shadow.iframe);
getApi().onShadowIframeLoad(gen);
});
expect(getApi().iframeRef.current).toBe(live);
expect(getApi().previewSlots).toHaveLength(2);
act(() => getApi().onShadowReadyChange(gen, true));
expect(getApi().iframeRef.current).toBe(shadow.iframe);
expect(getApi().previewSlots).toEqual([{ gen, role: "live", url: expect.any(String) }]);
unmount(root);
});
it("promotes when the loader clears before the adapter is ready", () => {
const { getApi, gen, root } = beginReload();
const shadow = makeShadowWithSpies();
act(() => {
getApi().setShadowIframeNode(shadow.iframe);
getApi().onShadowReadyChange(gen, true);
});
expect(getApi().previewSlots).toHaveLength(2);
act(() => getApi().onShadowIframeLoad(gen));
expect(getApi().iframeRef.current).toBe(shadow.iframe);
unmount(root);
});
it("drops a shadow that never becomes ready, keeps the live frame and reports why", () => {
vi.useFakeTimers();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const onPreviewReloadFailed = vi.fn();
const { getApi, live, root } = beginReload({ onPreviewReloadFailed });
expect(getApi().previewSlots).toHaveLength(2);
act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS));
expect(getApi().previewSlots).toEqual([{ gen: 0, role: "live" }]);
expect(getApi().iframeRef.current).toBe(live);
expect(live.style.visibility).toBe("");
expect(onPreviewReloadFailed).toHaveBeenCalledWith(expect.stringContaining("too long"));
expect(consoleError).toHaveBeenCalled();
unmount(root);
});
it("keeps a slow shadow pending past 5s and fails it only at the single budget", () => {
vi.useFakeTimers();
vi.spyOn(console, "error").mockImplementation(() => {});
const onPreviewReloadFailed = vi.fn();
const { getApi, gen, root } = beginReload({ onPreviewReloadFailed });
const silent = makeFakeIframe({});
act(() => {
getApi().setShadowIframeNode(silent);
getApi().onShadowIframeLoad(gen);
});
act(() => void vi.advanceTimersByTime(5001));
expect(getApi().previewSlots).toHaveLength(2);
expect(onPreviewReloadFailed).not.toHaveBeenCalled();
act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS));
expect(getApi().previewSlots).toEqual([{ gen: 0, role: "live" }]);
expect(onPreviewReloadFailed).toHaveBeenCalledWith(expect.stringContaining("too long"));
unmount(root);
});
it("drops a shadow whose document reports an error and reports the cause", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const onPreviewReloadFailed = vi.fn();
const { getApi, live, gen, root } = beginReload({ onPreviewReloadFailed });
act(() => getApi().onShadowError(gen, "Composition timeline not found after 8s"));
expect(getApi().previewSlots).toEqual([{ gen: 0, role: "live" }]);
expect(getApi().iframeRef.current).toBe(live);
expect(onPreviewReloadFailed).toHaveBeenCalledWith(
expect.stringContaining("Composition timeline not found after 8s"),
);
unmount(root);
});
it("runs no load side effects for a superseded shadow", () => {
const { getApi, gen, root } = beginReload();
usePlayerStore.getState().setCurrentTime(7);
const stale = makeShadowWithSpies();
act(() => {
getApi().setShadowIframeNode(stale.iframe);
getApi().refreshPlayer();
});
act(() => getApi().onShadowIframeLoad(gen));
expect(stale.adapter.seek).not.toHaveBeenCalled();
unmount(root);
});
});
describe("shadow reload store ownership and readiness", () => {
function setup(options?: Parameters<typeof renderTimelinePlayerHarness>[0]) {
const harness = renderTimelinePlayerHarness(options);
const live = makeAdapterWindow({ duration: 10 });
const liveIframe = makeFakeIframe(live.win);
act(() => {
harness.getApi().iframeRef.current = liveIframe;
harness.getApi().onIframeLoad();
});
act(() => harness.getApi().refreshPlayer());
const gen = harness.getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
const shadow = makeAdapterWindow({ duration: 42 });
const shadowIframe = makeFakeIframe(shadow.win);
act(() => harness.getApi().setShadowIframeNode(shadowIframe));
return { ...harness, gen, shadowIframe, liveIframe };
}
it("does not promote when the loader re-raises after it had cleared", () => {
const { getApi, gen, liveIframe, root } = setup();
act(() => {
getApi().onShadowReadyChange(gen, true);
getApi().onShadowReadyChange(gen, false);
getApi().onShadowIframeLoad(gen);
});
expect(getApi().iframeRef.current).toBe(liveIframe);
act(() => getApi().onShadowReadyChange(gen, true));
expect(getApi().iframeRef.current).not.toBe(liveIframe);
unmount(root);
});
it("keeps the old timeline in the store until the shadow is promoted", () => {
const { getApi, gen, root } = setup();
const before = usePlayerStore.getState().duration;
act(() => getApi().onShadowIframeLoad(gen));
expect(usePlayerStore.getState().duration).toBe(before);
act(() => getApi().onShadowReadyChange(gen, true));
expect(usePlayerStore.getState().duration).toBe(42);
unmount(root);
});
it("never writes a failed shadow's timeline into the store", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const { getApi, gen, root } = setup();
const before = usePlayerStore.getState().duration;
act(() => getApi().onShadowIframeLoad(gen));
act(() => getApi().onShadowError(gen, "boom"));
expect(usePlayerStore.getState().duration).toBe(before);
unmount(root);
});
it("a composition switch clears the refresh flags so the next load restores the store playhead", () => {
const { getApi, liveIframe, root } = setup();
const seek = vi.fn();
(liveIframe.contentWindow as unknown as { __player: { seek: unknown } }).__player.seek = seek;
usePlayerStore.getState().setCurrentTime(7);
act(() => getApi().resetPreviewSlots());
act(() => getApi().onIframeLoad());
expect(seek).toHaveBeenCalledWith(7);
unmount(root);
});
it("uses the latest failure callback for a timer armed before it changed", () => {
vi.useFakeTimers();
vi.spyOn(console, "error").mockImplementation(() => {});
const first = vi.fn();
const latest = vi.fn();
const { getApi, rerender, root } = setup({ onPreviewReloadFailed: first });
expect(getApi().previewSlots).toHaveLength(2);
rerender({ onPreviewReloadFailed: latest });
act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS));
expect(latest).toHaveBeenCalled();
expect(first).not.toHaveBeenCalled();
unmount(root);
});
});
describe("NLEProvider iframe ref notifications", () => {
it("tells the consumer about the reloaded iframe once, on promotion", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("{}", { status: 404 })),
);
const { ensureMotionPathPluginLoaded } = await import("../../utils/gsapSoftReload");
const onIframeRef = vi.fn();
let ctx: NLEContextValue | null = null;
const Probe = () => {
ctx = useNLEContext();
return null;
};
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const render = (refreshKey: number) =>
act(async () => {
root.render(
React.createElement(
NLEProvider,
{ projectId: "demo", refreshKey, onIframeRef },
React.createElement(Probe),
),
);
await Promise.resolve();
});
await render(0);
const live = makeLiveIframe();
await act(async () => {
ctx!.iframeRef.current = live;
ctx!.onIframeLoad();
});
onIframeRef.mockClear();
vi.mocked(ensureMotionPathPluginLoaded).mockClear();
await render(1);
const callsAtBegin = onIframeRef.mock.calls.length;
expect(onIframeRef.mock.calls.every(([iframe]) => iframe === live)).toBe(true);
const shadow = ctx!.previewSlots.find((s) => s.role === "shadow")!;
const shadowIframe = makeLiveIframe();
await act(async () => {
ctx!.setShadowIframeNode(shadowIframe);
ctx!.onShadowIframeLoad(shadow.gen);
ctx!.onShadowReadyChange(shadow.gen, true);
});
expect(onIframeRef).toHaveBeenCalledTimes(callsAtBegin + 1);
expect(onIframeRef).toHaveBeenLastCalledWith(shadowIframe);
expect(ensureMotionPathPluginLoaded).toHaveBeenCalledWith(shadowIframe);
await act(async () => root.unmount());
vi.unstubAllGlobals();
});
});
function unmount(root: ReturnType<typeof createRoot>) {
act(() => {
root.unmount();
});
}
@@ -3,6 +3,7 @@ import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerS
import { useMountEffect } from "../../hooks/useMountEffect";
import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
import { useShadowPreviewReload } from "./useShadowPreviewReload";
import { resolvePlaybackAdapter } from "./playbackAdapterResolution";
import { useTimelinePlayerLoop } from "./useTimelinePlayerLoop";
import { logReload } from "../../utils/reloadDebug";
@@ -38,7 +39,17 @@ import { createPreviewMessageHandler } from "./previewMessageRouter";
import { timelineElementsChanged } from "./timelinePlayerSync";
import { safeContentDocument } from "./timelineSyncHydration";
export function useTimelinePlayer() {
export interface UseTimelinePlayerOptions {
/** Runs right after a reloaded preview becomes the live iframe. */
onShadowPromoted?: () => void;
/** A reload was abandoned (cause in the message); the previous preview is still showing. */
onPreviewReloadFailed?: (message: string) => void;
}
export function useTimelinePlayer({
onShadowPromoted,
onPreviewReloadFailed,
}: UseTimelinePlayerOptions = {}) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const rafRef = useRef<number>(0);
const probeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
@@ -119,19 +130,23 @@ export function useTimelinePlayer() {
[setElements, requestTimelineReady, setDuration],
);
const getAdapter = useCallback((): PlaybackAdapter | null => {
try {
const iframe = iframeRef.current;
const win = iframe?.contentWindow as IframeWindow | null;
if (!iframe || !win) return null;
return resolvePlaybackAdapter(iframe, win, {
cache: staticSeekAdapterRef,
warned: staticSeekWarnedRef,
});
} catch {
return null;
}
}, []);
const getAdapter = useCallback(
(overrideIframe?: HTMLIFrameElement | null): PlaybackAdapter | null => {
try {
// undefined means "no override"; an explicit null override means "no adapter".
const iframe = overrideIframe !== undefined ? overrideIframe : iframeRef.current;
const win = iframe?.contentWindow as IframeWindow | null;
if (!iframe || !win) return null;
return resolvePlaybackAdapter(iframe, win, {
cache: staticSeekAdapterRef,
warned: staticSeekWarnedRef,
});
} catch {
return null;
}
},
[],
);
const { startRAFLoop, stopRAFLoop, stopReverseLoop } = useTimelinePlayerLoop({
rafRef,
@@ -361,6 +376,32 @@ export function useTimelinePlayer() {
attachIframeShortcutListeners,
applyPreviewAudioState,
});
// Full-reload edits load behind a hidden shadow iframe (useShadowPreviewReload.ts).
const {
previewSlots,
onShadowIframeLoad,
onShadowReadyChange,
onShadowError,
setShadowIframeNode,
beginShadowReload,
resetPreviewSlots,
} = useShadowPreviewReload({
iframeRef,
getAdapter,
pendingSeekRef,
isRefreshingRef,
syncTimelineElements,
setDuration,
setCurrentTime,
requestTimelineReady,
setIsPlaying,
attachIframeShortcutListeners,
applyPreviewAudioState,
onPromoted: onShadowPromoted,
onReloadFailed: onPreviewReloadFailed,
});
const saveSeekPosition = useCallback(() => {
// Never DEGRADE the saved position. Overlapping reloads (e.g. an external
// file drop = upload reload + insert reload back-to-back) call this while
@@ -390,22 +431,15 @@ export function useTimelinePlayer() {
if (!iframe) return;
logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
saveSeekPosition();
// Hide the iframe across the full reload so the user never sees the reloading
// document's RAW DOM (every clip stacked and visible) in the window between the
// new document parsing and the runtime initializing + seeking. initializeAdapter
// reveals it again right after its restore seek renders the correct frame.
// Tradeoff: this shows the parent stage background (a brief "freeze"/blank, on
// the order of the reload time ~100-300ms) INSTEAD of the all-clips flash. A
// blank is far less jarring than a burst of every asset appearing at once.
// Only the FULL-reload edits (drops/inserts) hit this — timing edits now take
// the soft-reload path and never touch refreshPlayer.
iframe.style.visibility = "hidden";
// The old iframe is no longer navigated away, so stop its playback (and audio) here.
getAdapter()?.pause();
// The live iframe is never hidden; the reload loads in a shadow and is promoted once painted.
const src = iframe.src;
const url = new URL(src, window.location.origin);
url.searchParams.set("_t", String(Date.now()));
applyPreviewVariablesToUrl(url);
iframe.src = url.toString();
}, [saveSeekPosition]);
beginShadowReload(url.toString());
}, [saveSeekPosition, getAdapter, beginShadowReload]);
const getAdapterRef = useRef(getAdapter);
getAdapterRef.current = getAdapter;
@@ -483,5 +517,11 @@ export function useTimelinePlayer() {
refreshPlayer,
saveSeekPosition,
resetPlayer,
previewSlots,
onShadowIframeLoad,
onShadowReadyChange,
onShadowError,
setShadowIframeNode,
resetPreviewSlots,
};
}
@@ -8,7 +8,7 @@
* - onIframeLoad — orchestrates initializeAdapter with a message-based fallback
*/
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import { liveTime, usePlayerStore } from "../store/playerStore";
import type { TimelineElement } from "../store/playerStore";
import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
@@ -33,7 +33,7 @@ import {
// Re-exported for the tests and callers that have always imported it from here.
export { resolveReloadSeekTime } from "./timelineSyncHydration";
interface UseTimelineSyncCallbacksParams {
export interface UseTimelineSyncCallbacksParams {
iframeRef: React.RefObject<HTMLIFrameElement | null>;
probeIntervalRef: React.MutableRefObject<ReturnType<typeof setInterval> | undefined>;
pendingSeekRef: React.MutableRefObject<number | null>;
@@ -46,6 +46,19 @@ interface UseTimelineSyncCallbacksParams {
setIsPlaying: (v: boolean) => void;
attachIframeShortcutListeners: () => void;
applyPreviewAudioState: () => void;
/**
* Fires once the restore-seek is issued and owns when `commit` (the store hydration) runs.
* The default reveals the iframe and commits at once; a shadow reload defers it to promotion.
*/
onAdapterReady?: (
iframe: HTMLIFrameElement | null,
context: number | undefined,
commit: () => void,
) => void;
/** False when the load for `context` was superseded: it must then cause no side effects. */
isCurrent?: (context?: number) => boolean;
/** Fires when no adapter appeared within the wait window. */
onLoadGiveUp?: (context?: number) => void;
}
/**
@@ -62,18 +75,54 @@ interface UseTimelineSyncCallbacksParams {
* resets currentTime on project switch. Invariant: an edit NEVER moves the
* playhead (the clamp below is the one sanctioned move — content shrank past it).
*/
/**
* Undo the `visibility: hidden` that refreshPlayer sets across a full reload.
* Safe to call when the iframe was never hidden (idempotent no-op). Every reload
* completion + failure path funnels through here so the preview can never get
* stuck invisible.
*/
/** Undo a hidden `visibility` on an iframe; idempotent no-op otherwise. */
export function revealIframe(iframe: HTMLIFrameElement | null): void {
if (iframe && iframe.style.visibility === "hidden") {
iframe.style.visibility = "";
}
}
function revealAndCommit(
iframe: HTMLIFrameElement | null,
_context: number | undefined,
commit: () => void,
): void {
revealIframe(iframe);
commit();
}
export type PreviewIframeRole = "live" | "shadow";
export interface PreviewIframeSlot {
gen: number;
role: PreviewIframeRole;
url?: string;
}
/** Queue a hidden shadow slot for the reload URL, replacing any earlier unpromoted shadow. */
export function planShadowReload(
slots: PreviewIframeSlot[],
nextGen: number,
url: string,
): PreviewIframeSlot[] {
const live = slots.find((slot) => slot.role === "live");
return live ? [live, { gen: nextGen, role: "shadow", url }] : [{ gen: nextGen, role: "live" }];
}
/** Drop any pending shadow; the live slot (and its key) is untouched. */
export function planShadowDiscard(slots: PreviewIframeSlot[]): PreviewIframeSlot[] {
return slots.filter((slot) => slot.role === "live");
}
/** Atomically make the ready shadow the only (live) slot; a stale readyGen is a no-op. */
export function planShadowPromotion(
slots: PreviewIframeSlot[],
readyGen: number,
): PreviewIframeSlot[] {
const ready = slots.find((slot) => slot.gen === readyGen && slot.role === "shadow");
return ready ? [{ ...ready, role: "live" }] : slots;
}
/**
* The transport TOTAL a clip-manifest message should write to the store.
*
@@ -110,6 +159,9 @@ export function useTimelineSyncCallbacks({
setIsPlaying,
attachIframeShortcutListeners,
applyPreviewAudioState,
onAdapterReady = revealAndCommit,
isCurrent,
onLoadGiveUp,
}: UseTimelineSyncCallbacksParams) {
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
const processTimelineMessage = useCallback(
@@ -183,94 +235,112 @@ export function useTimelineSyncCallbacks({
} catch {}
}, [iframeRef, syncTimelineElements]);
const initializeAdapter = useCallback(() => {
const adapter = getAdapter();
if (!adapter || adapter.getDuration() <= 0) return false;
const initializeAdapter = useCallback(
(context?: number) => {
if (isCurrent && !isCurrent(context)) return true;
const adapter = getAdapter();
if (!adapter || adapter.getDuration() <= 0) return false;
adapter.pause();
const startTime = seekAdapterToRestorePoint(adapter, pendingSeekRef);
// The correct frame is now rendered — reveal the iframe that refreshPlayer hid
// for the reload, so the user sees the restored frame directly (never the raw
// all-clips DOM). Cleared unconditionally: any later failure path must not leave
// the preview stuck invisible.
revealIframe(iframeRef.current);
// Keep non-React listeners such as the capture link and time display in sync
// with the initial adapter seek on iframe load.
liveTime.notify(startTime);
syncAdapterDuration(adapter, setDuration);
setCurrentTime(startTime);
if (!isRefreshingRef.current) {
// Enables Play from actual play-readiness, not just a known duration —
// a click before this resolves used to start the timeline with media,
// images or fonts still loading and never recover.
requestTimelineReady(safeContentDocument(iframeRef.current));
}
isRefreshingRef.current = false;
setIsPlaying(false);
adapter.pause();
const startTime = seekAdapterToRestorePoint(adapter, pendingSeekRef);
const commit = () => {
// Keep non-React listeners such as the capture link and time display in sync
// with the initial adapter seek on iframe load.
liveTime.notify(startTime);
syncAdapterDuration(adapter, setDuration);
setCurrentTime(startTime);
if (!isRefreshingRef.current) {
// Enables Play from actual play-readiness, not just a known duration —
// a click before this resolves used to start the timeline with media,
// images or fonts still loading and never recover.
requestTimelineReady(safeContentDocument(iframeRef.current));
}
isRefreshingRef.current = false;
setIsPlaying(false);
hydrateTimelineFromPreview({
iframe: iframeRef.current,
adapter,
hydrateTimelineFromPreview({
iframe: iframeRef.current,
adapter,
processTimelineMessage,
enrichMissingCompositions,
applyPreviewAudioState,
attachIframeShortcutListeners,
syncTimelineElements,
});
};
onAdapterReady(iframeRef.current, context, commit);
return true;
},
[
getAdapter,
setDuration,
setCurrentTime,
requestTimelineReady,
setIsPlaying,
processTimelineMessage,
enrichMissingCompositions,
applyPreviewAudioState,
attachIframeShortcutListeners,
syncTimelineElements,
});
return true;
}, [
getAdapter,
setDuration,
setCurrentTime,
requestTimelineReady,
setIsPlaying,
processTimelineMessage,
enrichMissingCompositions,
syncTimelineElements,
attachIframeShortcutListeners,
applyPreviewAudioState,
iframeRef,
isRefreshingRef,
pendingSeekRef,
]);
attachIframeShortcutListeners,
applyPreviewAudioState,
onAdapterReady,
isCurrent,
iframeRef,
isRefreshingRef,
pendingSeekRef,
],
);
const onIframeLoad = useCallback(() => {
applyPreviewAudioState();
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
// Drops the previous load's readiness listener so a superseded load cannot settle later.
const stopWaitingRef = useRef<(() => void) | null>(null);
// Fast path: adapter already available (in-place reloads, cached compositions)
if (initializeAdapter()) return;
const onIframeLoad = useCallback(
(context?: number) => {
applyPreviewAudioState();
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
stopWaitingRef.current?.();
stopWaitingRef.current = null;
// The runtime posts "state" or "timeline" messages once ready.
// Listen for those instead of polling.
const iframe = iframeRef.current;
let settled = false;
// Fast path: adapter already available (in-place reloads, cached compositions)
if (initializeAdapter(context)) return;
const trySettle = () => {
if (settled) return;
if (initializeAdapter()) {
settled = true;
// The runtime posts "state" or "timeline" messages once ready.
// Listen for those instead of polling.
const iframe = iframeRef.current;
let settled = false;
const trySettle = () => {
if (settled) return;
if (initializeAdapter(context)) {
settled = true;
window.removeEventListener("message", onMessage);
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
}
};
const onMessage = (e: MessageEvent) => {
if (isPreviewReadinessMessage(e, iframe)) trySettle();
};
window.addEventListener("message", onMessage);
stopWaitingRef.current = () => window.removeEventListener("message", onMessage);
// Safety net: if no message arrives within 5s, try one last time then give up.
probeIntervalRef.current = setTimeout(() => {
if (!settled) {
trySettle();
}
window.removeEventListener("message", onMessage);
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
}
};
if (!settled) onLoadGiveUp?.(context);
revealIframe(iframeRef.current);
}, 5000) as unknown as ReturnType<typeof setInterval>;
},
[initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState, onLoadGiveUp],
);
const onMessage = (e: MessageEvent) => {
if (isPreviewReadinessMessage(e, iframe)) trySettle();
};
window.addEventListener("message", onMessage);
// Safety net: if no message arrives within 5s, try one last time then give up.
probeIntervalRef.current = setTimeout(() => {
if (!settled) {
trySettle();
}
window.removeEventListener("message", onMessage);
// Never leave the preview stuck invisible if the runtime never settled
// (initializeAdapter reveals on success; this covers the give-up case).
revealIframe(iframeRef.current);
}, 5000) as unknown as ReturnType<typeof setInterval>;
}, [initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState]);
const cancelPendingLoad = useCallback(() => {
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
stopWaitingRef.current?.();
stopWaitingRef.current = null;
}, [probeIntervalRef]);
// Stable refs so mount-effect closures always call the latest version
const processTimelineMessageRef = { current: processTimelineMessage };
@@ -283,5 +353,6 @@ export function useTimelineSyncCallbacks({
enrichMissingCompositionsRef,
initializeAdapter,
onIframeLoad,
cancelPendingLoad,
};
}
@@ -0,0 +1,26 @@
export interface PreviewCompositionSize {
width: number;
height: number;
}
/** The root composition's authored size, read from the preview document itself. */
// fallow-ignore-next-line complexity
export function readPreviewCompositionSize(
iframe: HTMLIFrameElement | null,
): PreviewCompositionSize | null {
try {
const doc = iframe?.contentDocument;
const root =
doc?.querySelector("[data-composition-id][data-width][data-height]") ??
doc?.querySelector("[data-width][data-height]");
if (!root) return null;
const width = Number.parseInt(root.getAttribute("data-width") ?? "", 10);
const height = Number.parseInt(root.getAttribute("data-height") ?? "", 10);
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
return null;
}
return { width, height };
} catch {
return null;
}
}
+2 -4
View File
@@ -1,10 +1,8 @@
// Preview full-reload diagnostics — grep [hf-reload]. Off by default; opt in per
// session with `localStorage.setItem("hf-reload-debug", "1")` (then reload).
//
// A full reload blanks the stage for ~100-300ms, so any reload the user did not
// ask for reads as a flash. These lines answer the only question that matters
// when one appears: who asked for it, and why the write that triggered it was
// not recognised as Studio's own.
// A full reload no longer blanks the stage (see useShadowPreviewReload.ts). These lines answer
// who asked for a reload and why the write was not recognised as Studio's own.
import { makeStudioDebugLogger } from "./studioDebug";
export const logReload = makeStudioDebugLogger("reload");