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
This commit is contained in:
Miguel Ángel
2026-10-18 05:46:53 +00:00
committed by GitHub
parent b74686ef8e
commit 2b9009ab52
11 changed files with 141 additions and 11 deletions
+1
View File
@@ -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`) |
+1
View File
@@ -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 |
+8 -7
View File
@@ -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;
@@ -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 } })),
});
@@ -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);
@@ -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<HTMLIFrameElement>(),
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());
});
@@ -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}
/>
),
@@ -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<HTMLIFrameElement, PlayerProps>(
portrait,
style,
suppressLoadingOverlay,
noLoadTimeout,
},
ref,
) => {
@@ -224,6 +226,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
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<HTMLIFrameElement, PlayerProps>(
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%";
@@ -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;
@@ -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<string, unknown> = { ...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(() => {});
@@ -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<typeof setInterval>;
},
[initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState, onLoadGiveUp],
[
initializeAdapter,
iframeRef,
probeIntervalRef,
applyPreviewAudioState,
onLoadGiveUp,
hostOwnsDeadline,
],
);
const cancelPendingLoad = useCallback(() => {