perf(studio): treat the preview as booted once it can play

Work deferred until the boot (editing session, lint, overlays, GSAP parse,
card thumbnails) started at timeline-ready, which fires before the asset gate,
so the editing-session parse ran inside the wait for play. The Player now
latches the boot when the preview can show and play, or fails to load.
This commit is contained in:
Miguel Ángel
2026-09-23 01:29:35 -04:00
parent 5d6ab335ff
commit 62a9861ba4
5 changed files with 31 additions and 15 deletions
@@ -20,7 +20,6 @@ import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesS
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry";
import { PreviewPoster, previewPosterUrl } from "./PreviewPoster";
import { usePlayerStore } from "../../player/store/playerStore";
interface NLEPreviewProps {
projectId: string;
iframeRef: RefObject<HTMLIFrameElement | null>;
@@ -525,10 +524,7 @@ export const NLEPreview = memo(function NLEPreview({
}}
onCompositionLoadingChange={onCompositionLoadingChange}
onReadyToShowChange={onLiveReadyToShowChange}
onPreviewError={() => {
setPosterDoneFor(activeKey);
usePlayerStore.getState().markPreviewBooted();
}}
onPreviewError={() => setPosterDoneFor(activeKey)}
onPainted={(details) => reportPreviewFirstFrame(slot, details)}
portrait={portrait}
suppressLoadingOverlay={suppressLoadingOverlay}
@@ -40,12 +40,12 @@ function mount(compositions: string[], masterCompositionPath: string | null) {
}
describe("CompositionsTab card thumbnails", () => {
it("waits for the live preview's first ready and keeps them through edit reloads", () => {
it("waits for the live preview to boot and keeps them through edit reloads", () => {
usePlayerStore.getState().reset();
const host = mount(["index.html"], "index.html");
expect(host.querySelector("img")).toBeNull();
act(() => usePlayerStore.getState().setTimelineReady(true));
act(() => usePlayerStore.getState().markPreviewBooted());
expect(host.querySelector("img")?.getAttribute("src")).toContain("/thumbnail/index.html");
act(() => usePlayerStore.getState().setTimelineReady(false));
@@ -9,6 +9,7 @@ import {
readPreviewErrorMessage,
shouldShowCompositionLoadingOverlay,
} from "./Player";
import { usePlayerStore } from "../store/playerStore";
vi.mock("@hyperframes/player", () => ({}));
@@ -271,6 +272,21 @@ describe("ready to show", () => {
expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
});
it("marks the preview booted when it can show and play, not at ready", async () => {
usePlayerStore.setState({ previewBooted: false });
const { player } = await mountPlayer({});
const el = player as TestHyperframesPlayer;
loadAndReady(el);
act(() => void el.dispatchEvent(new Event("assetsready")));
await twoFrames();
expect(usePlayerStore.getState().previewBooted).toBe(false);
painted(el);
await twoFrames();
expect(usePlayerStore.getState().previewBooted).toBe(true);
});
it("reports the document start time with the painted iframe", async () => {
const onPainted = vi.fn();
const now = vi.spyOn(performance, "now").mockReturnValue(100);
@@ -4,6 +4,7 @@ import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readin
import { useMountEffect } from "../../hooks/useMountEffect";
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
import { HyperframesLoader } from "../../components/ui";
import { usePlayerStore } from "../store/playerStore";
// Importing "@hyperframes/player" registers a class extending HTMLElement at
// module load, which throws under SSR, hence the dynamic import behind a
// `typeof window` guard. Kicking it here rather than in the mount effect puts
@@ -440,6 +441,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
onReadyToShowChangeRef.current?.(false);
return;
}
// The preview shows and can play: work that waited on the boot (editing session, lint) starts.
usePlayerStore.getState().markPreviewBooted();
let second = 0;
const first = requestAnimationFrame(() => {
second = requestAnimationFrame(() => onReadyToShowChangeRef.current?.(true));
@@ -450,6 +453,10 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
};
}, [readyToShow]);
useEffect(() => {
if (previewError) usePlayerStore.getState().markPreviewBooted();
}, [previewError]);
const showCompositionOverlay =
!suppressLoadingOverlay &&
!compositionOverlayDeferred &&
@@ -8,11 +8,10 @@ import { settleCompositionReadiness } from "@hyperframes/core/composition-readin
export interface PlaybackReadinessSlice {
timelineReady: boolean;
/** Latched by the project's first ready preview and kept through edit reloads, so work that
* must not compete with boot (card thumbnails) waits for it once. */
/** Latched when the project's first preview shows and can play (or fails), kept through edit
* reloads, so work that must not compete with the boot waits for it once. */
previewBooted: boolean;
setTimelineReady: (ready: boolean) => void;
/** A live preview that failed to load has still finished booting. */
markPreviewBooted: () => void;
/** Sets timelineReady once doc's readiness inputs settle, or immediately
* if doc is null. A wait a later call supersedes never wins the race. */
@@ -40,15 +39,13 @@ export function createPlaybackReadinessSlice(
markPreviewBooted: () => set({ previewBooted: true }),
setTimelineReady: (ready) => {
timelineReadyGeneration++;
set(ready ? { timelineReady: true, previewBooted: true } : { timelineReady: false });
set({ timelineReady: ready });
},
requestTimelineReady: (doc) => {
const generation = ++timelineReadyGeneration;
if (!doc) return set({ timelineReady: true, previewBooted: true });
if (!doc) return set({ timelineReady: true });
settleCompositionReadiness(doc, () => {
if (generation === timelineReadyGeneration) {
set({ timelineReady: true, previewBooted: true });
}
if (generation === timelineReadyGeneration) set({ timelineReady: true });
});
},
};