From 2b9009ab520a31fa7f23cbad44583733a6602abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 18 Oct 2026 05:46:53 +0000 Subject: [PATCH] fix(studio): keep a preview reload waiting while a slow project boots (#5823) * fix(studio): keep a preview reload waiting while a slow project boots Studio gives a reload up to three 15 s budgets, but the player failed it at 8 s and Studio's own 5 s safety net stopped listening for the runtime. A host can now own the load deadline (no-load-timeout); script and runtime errors still fail at once. * fix(player): send the 8 s load deadline through the probe's one error path --- docs/packages/player.mdx | 1 + packages/player/README.md | 1 + packages/player/src/composition-probe.ts | 15 ++--- packages/player/src/hyperframes-player.ts | 1 + .../player/src/probeTerminalReadiness.test.ts | 18 ++++++ .../nle/NLEPreview.slowBoot.test.ts | 63 +++++++++++++++++++ .../studio/src/components/nle/NLEPreview.tsx | 4 +- .../studio/src/player/components/Player.tsx | 4 ++ .../player/hooks/useShadowPreviewReload.ts | 8 ++- .../useTimelinePlayer.shadowReload.test.ts | 25 ++++++++ .../player/hooks/useTimelineSyncCallbacks.ts | 12 +++- 11 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 packages/studio/src/components/nle/NLEPreview.slowBoot.test.ts diff --git a/docs/packages/player.mdx b/docs/packages/player.mdx index 60d435fadb..c9cbd76bbf 100644 --- a/docs/packages/player.mdx +++ b/docs/packages/player.mdx @@ -64,6 +64,7 @@ Set `autoplay muted` only when playback should start without a user gesture. | `assets-loading-ui` | `player` or `none` | `player` | `none` hides the loading-assets card; asset events still fire | | `low-power-idle` | boolean | false | While paused, check in once a second, not every 80 ms (many-player pages) | | `disable-click-to-play` | boolean | false | A click on the player no longer plays or pauses, so the page can handle it | +| `no-load-timeout` | boolean | false | A slow load never fails at 8 s, so the page owns the deadline; script and runtime errors still fail at once | | `range-start` | number | — | Film second where playback starts, loops back to and parks when paused (`rangeStart`) | | `range-end` | number | — | Film second the range ends before: it stops or loops on the frame before it (`rangeEnd`) | diff --git a/packages/player/README.md b/packages/player/README.md index 329c6e54db..2358ab2b83 100644 --- a/packages/player/README.md +++ b/packages/player/README.md @@ -84,6 +84,7 @@ Show a static image before playback starts: | `assets-loading-ui` | `player \| none` | `player` | `none` never shows the loading-assets card; asset events still fire | | `low-power-idle` | boolean | false | While paused, check in once a second, not every 80 ms (many-player pages) | | `disable-click-to-play` | boolean | false | A click on the player no longer plays or pauses (host overlays own clicks) | +| `no-load-timeout` | boolean | false | Slow loads never fail at 8 s; the page owns the deadline. Errors still fail | | `range-start` | number | — | Film second where playback starts, loops back to and parks when paused | | `range-end` | number | — | Film second the range ends before: it stops or loops on the frame before it | diff --git a/packages/player/src/composition-probe.ts b/packages/player/src/composition-probe.ts index 3ccf3f1f05..f5630146c2 100644 --- a/packages/player/src/composition-probe.ts +++ b/packages/player/src/composition-probe.ts @@ -4,7 +4,7 @@ * * The probe interval polls every 200 ms until one of: * - An adapter or the document resolves with a positive duration, or - * - 40 attempts (~8 s) expire without a result. + * - 40 attempts (~8 s) expire without a result, unless the host owns the deadline. * * The `CompositionProbe` class owns the interval; the caller must call * `stop()` on disconnect or src change. @@ -38,6 +38,7 @@ export interface ProbeCallbacks { /** Called when runtime is successfully injected (informational). */ onRuntimeInjected?: () => void; resolveRuntimeUrl?: () => string; + hostOwnsDeadline?: () => boolean; } /** @@ -121,22 +122,22 @@ export class CompositionProbe { }; } } + outcome ??= this._deadlineOutcome(attempts); if (outcome) { this.stop(); if (outcome.kind === "error") { this._failure = { document: this._iframe.contentDocument }; this._callbacks.onError(outcome.message); } else this._callbacks.onReady(outcome.result); - return; - } - if (attempts >= 40) { - this.stop(); - this._failure = { document: this._iframe.contentDocument }; - this._callbacks.onError("Composition timeline not found after 8s"); } }, 200); } + private _deadlineOutcome(attempts: number): ProbeOutcome { + if (attempts < 40 || this._callbacks.hostOwnsDeadline?.()) return null; + return { kind: "error", message: "Composition timeline not found after 8s" }; + } + private _poll(attempts: number): ProbeOutcome { const win = this._iframe.contentWindow; if (!win) return null; diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index e6f753797d..0b7f78e790 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -206,6 +206,7 @@ class HyperframesPlayer extends HTMLElement { this.probe = new CompositionProbe(this.iframe, { resolveRuntimeUrl: () => runtimeSrcFromElement(this), + hostOwnsDeadline: () => this.hasAttribute("no-load-timeout"), onReady: (result) => this._onProbeReady(result), onError: (message) => this._emit(new CustomEvent("error", { detail: { message } })), }); diff --git a/packages/player/src/probeTerminalReadiness.test.ts b/packages/player/src/probeTerminalReadiness.test.ts index fbc0f13ba3..6064647314 100644 --- a/packages/player/src/probeTerminalReadiness.test.ts +++ b/packages/player/src/probeTerminalReadiness.test.ts @@ -61,6 +61,24 @@ describe("terminal probe readiness", () => { expectUnready(player, onReady); }); + it("waits past 8 s for a slow runtime when the host owns the deadline", () => { + const { player, iframe, onError, onReady } = mountPlayer(); + player.setAttribute("no-load-timeout", ""); + vi.advanceTimersByTime(20_000); + expect(onError).not.toHaveBeenCalled(); + Object.assign(iframe.contentWindow!, { __player: { getDuration: () => 9 } }); + vi.advanceTimersByTime(200); + expectTimelineReady(player, onReady); + }); + + it("still fails an author error at once when the host owns the deadline", () => { + const { player, iframe, onError } = mountPlayer(); + player.setAttribute("no-load-timeout", ""); + Object.assign(iframe.contentWindow!, { __hfPreviewErrors: ["Uncaught Error: author failed"] }); + vi.advanceTimersByTime(200); + expect(onError.mock.calls[0][0].detail.message).toBe("Uncaught Error: author failed"); + }); + it("rejects the failed document while its replacement is still navigating", () => { const { player, iframe, onReady } = mountPlayer(); vi.advanceTimersByTime(8000); diff --git a/packages/studio/src/components/nle/NLEPreview.slowBoot.test.ts b/packages/studio/src/components/nle/NLEPreview.slowBoot.test.ts new file mode 100644 index 0000000000..f5d3e5524e --- /dev/null +++ b/packages/studio/src/components/nle/NLEPreview.slowBoot.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom +// @vitest-environment-options {"happyDOM":{"settings":{"disableIframePageLoading":true}}} + +import React, { act, createRef } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vitest"; +import { NLEPreview } from "./NLEPreview"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ""; +}); + +it("keeps a shadow reload whose runtime boots after 8 s, and its player comes ready", async () => { + await import("@hyperframes/player"); + vi.useFakeTimers(); + const onShadowError = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + await act(async () => { + root.render( + React.createElement(NLEPreview, { + projectId: "p", + iframeRef: createRef(), + onIframeLoad: () => {}, + previewSlots: [ + { gen: 0, role: "live" }, + { gen: 1, role: "shadow", url: "/api/projects/p/preview?_t=1" }, + ], + onShadowIframeLoad: () => {}, + onShadowReadyChange: () => {}, + onShadowError, + setShadowIframeNode: () => {}, + resetPreviewSlots: () => {}, + }), + ); + }); + + const shadow = host.querySelectorAll< + HTMLElement & { iframeElement: HTMLIFrameElement; ready: boolean } + >("hyperframes-player")[1]; + const iframe = shadow.iframeElement; + act(() => { + iframe.dispatchEvent(new Event("load")); + }); + act(() => { + vi.advanceTimersByTime(10_000); + }); + expect(onShadowError).not.toHaveBeenCalled(); + + Object.assign(iframe.contentWindow!, { __player: { getDuration: () => 9 } }); + act(() => { + vi.advanceTimersByTime(1_000); + }); + expect(onShadowError).not.toHaveBeenCalled(); + expect(shadow.ready).toBe(true); + // The live slot keeps the player's own 8 s deadline. + expect(host.querySelector("hyperframes-player")?.hasAttribute("no-load-timeout")).toBe(false); + act(() => root.unmount()); +}); diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 8cb82c9dc0..be4831bd4e 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -209,8 +209,7 @@ export const NLEPreview = memo(function NLEPreview({ const updateCompositionSizeFromPreview = useCallback(() => { const next = readPreviewCompositionSize(previewIframeRef.current); - // Pure updater — the parent notification happens in the effect below - // (updaters may run more than once under Strict Mode / concurrent React). + // Pure updater; the effect below notifies the parent (Strict Mode may run updaters twice). setCompositionSize((prev) => prev?.width === next?.width && prev?.height === next?.height ? prev : next, ); @@ -563,6 +562,7 @@ export const NLEPreview = memo(function NLEPreview({ onPreviewError={(message) => onShadowError(slot.gen, message)} portrait={portrait} suppressLoadingOverlay + noLoadTimeout style={SHADOW_IFRAME_STYLE} /> ), diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index ea441c2028..cc74e665b6 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -37,6 +37,7 @@ interface PlayerProps { portrait?: boolean; style?: React.CSSProperties; suppressLoadingOverlay?: boolean; + noLoadTimeout?: boolean; } interface HyperframesPlayerElement extends HTMLElement { @@ -154,6 +155,7 @@ export const Player = forwardRef( portrait, style, suppressLoadingOverlay, + noLoadTimeout, }, ref, ) => { @@ -224,6 +226,7 @@ export const Player = forwardRef( setCompositionLoading(true); loadStartedAtRef.current = performance.now(); loadIdRef.current += 1; + player.removeAttribute("no-load-timeout"); player.setAttribute("src", retryUrl.pathname + retryUrl.search); }; retryPreviewRef.current = retryPreview; @@ -331,6 +334,7 @@ export const Player = forwardRef( player.setAttribute("shader-capture-scale", "1"); player.setAttribute("shader-loading", "player"); player.setAttribute("low-power-idle", ""); + player.toggleAttribute("no-load-timeout", noLoadTimeout === true); player.setAttribute("width", String(portrait ? 1080 : 1920)); player.setAttribute("height", String(portrait ? 1920 : 1080)); player.style.width = "100%"; diff --git a/packages/studio/src/player/hooks/useShadowPreviewReload.ts b/packages/studio/src/player/hooks/useShadowPreviewReload.ts index 7233c2a6c6..39e114649d 100644 --- a/packages/studio/src/player/hooks/useShadowPreviewReload.ts +++ b/packages/studio/src/player/hooks/useShadowPreviewReload.ts @@ -49,7 +49,12 @@ function isDocumentHidden(): boolean { type UseShadowPreviewReloadParams = Omit< UseTimelineSyncCallbacksParams, - "probeIntervalRef" | "onAdapterReady" | "getAdapter" | "isCurrent" | "onLoadGiveUp" + | "probeIntervalRef" + | "onAdapterReady" + | "getAdapter" + | "isCurrent" + | "onLoadGiveUp" + | "hostOwnsDeadline" > & { getAdapter: (overrideIframe?: HTMLIFrameElement | null) => PlaybackAdapter | null; /** Runs right after a shadow becomes the live iframe (iframeRef already points at it). */ @@ -249,6 +254,7 @@ export function useShadowPreviewReload({ applyPreviewAudioState: () => {}, onAdapterReady: markAdapterReady, isCurrent: isCurrentShadow, + hostOwnsDeadline: true, }); cancelPendingLoadRef.current = cancelPendingLoad; diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts index a1006cafe8..6770367ddc 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts @@ -461,6 +461,31 @@ describe("shadow reload readiness and failure", () => { unmount(root); }); + it("promotes a shadow whose runtime boots after Studio's own 5 s readiness wait", () => { + vi.useFakeTimers(); + const onPreviewReloadFailed = vi.fn(); + const { getApi, gen, root } = beginReload({ onPreviewReloadFailed }); + const { win } = makeAdapterWindow(); + const booting: Record = { ...win, __player: undefined }; + const iframe = makeFakeIframe(booting); + act(() => { + getApi().setShadowIframeNode(iframe); + getApi().onShadowIframeLoad(gen); + }); + + act(() => void vi.advanceTimersByTime(10_000)); + booting.__player = win.__player; + act(() => { + window.dispatchEvent( + new MessageEvent("message", { data: { source: "hf-preview", type: "state" } }), + ); + getApi().onShadowReadyChange(gen, true); + }); + expect(getApi().iframeRef.current).toBe(iframe); + expect(onPreviewReloadFailed).not.toHaveBeenCalled(); + unmount(root); + }); + it("gives a reload that replaces a slow one its own full set of budgets", () => { vi.useFakeTimers(); vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts index e0231bf32b..1157007b67 100644 --- a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts +++ b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts @@ -60,6 +60,7 @@ export interface UseTimelineSyncCallbacksParams { isCurrent?: (context?: number) => boolean; /** Fires when no adapter appeared within the wait window. */ onLoadGiveUp?: (context?: number) => void; + hostOwnsDeadline?: boolean; } /** @@ -163,6 +164,7 @@ export function useTimelineSyncCallbacks({ onAdapterReady = revealAndCommit, isCurrent, onLoadGiveUp, + hostOwnsDeadline, }: UseTimelineSyncCallbacksParams) { // Convert a runtime timeline message (from iframe postMessage) into TimelineElements const processTimelineMessage = useCallback( @@ -335,6 +337,7 @@ export function useTimelineSyncCallbacks({ }; window.addEventListener("message", onMessage); stopWaitingRef.current = stopWaiting; + if (hostOwnsDeadline) return; // Safety net: if no message arrives within 5s, try one last time then give up. probeIntervalRef.current = setTimeout(() => { @@ -346,7 +349,14 @@ export function useTimelineSyncCallbacks({ revealIframe(iframeRef.current); }, 5000) as unknown as ReturnType; }, - [initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState, onLoadGiveUp], + [ + initializeAdapter, + iframeRef, + probeIntervalRef, + applyPreviewAudioState, + onLoadGiveUp, + hostOwnsDeadline, + ], ); const cancelPendingLoad = useCallback(() => {