From 62a9861ba44df5996877d9aa65d07714f2ee2a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 22 Sep 2026 22:25:54 -0400 Subject: [PATCH] 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. --- .../studio/src/components/nle/NLEPreview.tsx | 6 +----- .../sidebar/CompositionsTab.rootBadge.test.tsx | 4 ++-- .../studio/src/player/components/Player.test.ts | 16 ++++++++++++++++ packages/studio/src/player/components/Player.tsx | 7 +++++++ .../studio/src/player/store/readinessSlice.ts | 13 +++++-------- 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 1c14c87dc3..9215be5232 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -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; @@ -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} diff --git a/packages/studio/src/components/sidebar/CompositionsTab.rootBadge.test.tsx b/packages/studio/src/components/sidebar/CompositionsTab.rootBadge.test.tsx index 9ce72b5e3b..b12ac31c0a 100644 --- a/packages/studio/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +++ b/packages/studio/src/components/sidebar/CompositionsTab.rootBadge.test.tsx @@ -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)); diff --git a/packages/studio/src/player/components/Player.test.ts b/packages/studio/src/player/components/Player.test.ts index 232e26393f..16f7cbaa93 100644 --- a/packages/studio/src/player/components/Player.test.ts +++ b/packages/studio/src/player/components/Player.test.ts @@ -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); diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index 5500f3db8b..68de472095 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -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( 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( }; }, [readyToShow]); + useEffect(() => { + if (previewError) usePlayerStore.getState().markPreviewBooted(); + }, [previewError]); + const showCompositionOverlay = !suppressLoadingOverlay && !compositionOverlayDeferred && diff --git a/packages/studio/src/player/store/readinessSlice.ts b/packages/studio/src/player/store/readinessSlice.ts index 34576f6a42..90fc6dfb41 100644 --- a/packages/studio/src/player/store/readinessSlice.ts +++ b/packages/studio/src/player/store/readinessSlice.ts @@ -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 }); }); }, };