Skip to content

Commit 2b9009a

Browse files
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
1 parent b74686e commit 2b9009a

11 files changed

Lines changed: 141 additions & 11 deletions

File tree

‎docs/packages/player.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ Set `autoplay muted` only when playback should start without a user gesture.
6464
| `assets-loading-ui` | `player` or `none` | `player` | `none` hides the loading-assets card; asset events still fire |
6565
| `low-power-idle` | boolean | false | While paused, check in once a second, not every 80 ms (many-player pages) |
6666
| `disable-click-to-play` | boolean | false | A click on the player no longer plays or pauses, so the page can handle it |
67+
| `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 |
6768
| `range-start` | number | — | Film second where playback starts, loops back to and parks when paused (`rangeStart`) |
6869
| `range-end` | number | — | Film second the range ends before: it stops or loops on the frame before it (`rangeEnd`) |
6970

‎packages/player/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ Show a static image before playback starts:
8484
| `assets-loading-ui` | `player \| none` | `player` | `none` never shows the loading-assets card; asset events still fire |
8585
| `low-power-idle` | boolean | false | While paused, check in once a second, not every 80 ms (many-player pages) |
8686
| `disable-click-to-play` | boolean | false | A click on the player no longer plays or pauses (host overlays own clicks) |
87+
| `no-load-timeout` | boolean | false | Slow loads never fail at 8 s; the page owns the deadline. Errors still fail |
8788
| `range-start` | number | — | Film second where playback starts, loops back to and parks when paused |
8889
| `range-end` | number | — | Film second the range ends before: it stops or loops on the frame before it |
8990

‎packages/player/src/composition-probe.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*
55
* The probe interval polls every 200 ms until one of:
66
* - An adapter or the document resolves with a positive duration, or
7-
* - 40 attempts (~8 s) expire without a result.
7+
* - 40 attempts (~8 s) expire without a result, unless the host owns the deadline.
88
*
99
* The `CompositionProbe` class owns the interval; the caller must call
1010
* `stop()` on disconnect or src change.
@@ -38,6 +38,7 @@ export interface ProbeCallbacks {
3838
/** Called when runtime is successfully injected (informational). */
3939
onRuntimeInjected?: () => void;
4040
resolveRuntimeUrl?: () => string;
41+
hostOwnsDeadline?: () => boolean;
4142
}
4243

4344
/**
@@ -121,22 +122,22 @@ export class CompositionProbe {
121122
};
122123
}
123124
}
125+
outcome ??= this._deadlineOutcome(attempts);
124126
if (outcome) {
125127
this.stop();
126128
if (outcome.kind === "error") {
127129
this._failure = { document: this._iframe.contentDocument };
128130
this._callbacks.onError(outcome.message);
129131
} else this._callbacks.onReady(outcome.result);
130-
return;
131-
}
132-
if (attempts >= 40) {
133-
this.stop();
134-
this._failure = { document: this._iframe.contentDocument };
135-
this._callbacks.onError("Composition timeline not found after 8s");
136132
}
137133
}, 200);
138134
}
139135

136+
private _deadlineOutcome(attempts: number): ProbeOutcome {
137+
if (attempts < 40 || this._callbacks.hostOwnsDeadline?.()) return null;
138+
return { kind: "error", message: "Composition timeline not found after 8s" };
139+
}
140+
140141
private _poll(attempts: number): ProbeOutcome {
141142
const win = this._iframe.contentWindow;
142143
if (!win) return null;

‎packages/player/src/hyperframes-player.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ class HyperframesPlayer extends HTMLElement {
206206

207207
this.probe = new CompositionProbe(this.iframe, {
208208
resolveRuntimeUrl: () => runtimeSrcFromElement(this),
209+
hostOwnsDeadline: () => this.hasAttribute("no-load-timeout"),
209210
onReady: (result) => this._onProbeReady(result),
210211
onError: (message) => this._emit(new CustomEvent("error", { detail: { message } })),
211212
});

‎packages/player/src/probeTerminalReadiness.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,24 @@ describe("terminal probe readiness", () => {
6161
expectUnready(player, onReady);
6262
});
6363

64+
it("waits past 8 s for a slow runtime when the host owns the deadline", () => {
65+
const { player, iframe, onError, onReady } = mountPlayer();
66+
player.setAttribute("no-load-timeout", "");
67+
vi.advanceTimersByTime(20_000);
68+
expect(onError).not.toHaveBeenCalled();
69+
Object.assign(iframe.contentWindow!, { __player: { getDuration: () => 9 } });
70+
vi.advanceTimersByTime(200);
71+
expectTimelineReady(player, onReady);
72+
});
73+
74+
it("still fails an author error at once when the host owns the deadline", () => {
75+
const { player, iframe, onError } = mountPlayer();
76+
player.setAttribute("no-load-timeout", "");
77+
Object.assign(iframe.contentWindow!, { __hfPreviewErrors: ["Uncaught Error: author failed"] });
78+
vi.advanceTimersByTime(200);
79+
expect(onError.mock.calls[0][0].detail.message).toBe("Uncaught Error: author failed");
80+
});
81+
6482
it("rejects the failed document while its replacement is still navigating", () => {
6583
const { player, iframe, onReady } = mountPlayer();
6684
vi.advanceTimersByTime(8000);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// @vitest-environment happy-dom
2+
// @vitest-environment-options {"happyDOM":{"settings":{"disableIframePageLoading":true}}}
3+
4+
import React, { act, createRef } from "react";
5+
import { createRoot } from "react-dom/client";
6+
import { afterEach, expect, it, vi } from "vitest";
7+
import { NLEPreview } from "./NLEPreview";
8+
9+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
10+
11+
afterEach(() => {
12+
vi.useRealTimers();
13+
document.body.innerHTML = "";
14+
});
15+
16+
it("keeps a shadow reload whose runtime boots after 8 s, and its player comes ready", async () => {
17+
await import("@hyperframes/player");
18+
vi.useFakeTimers();
19+
const onShadowError = vi.fn();
20+
const host = document.createElement("div");
21+
document.body.append(host);
22+
const root = createRoot(host);
23+
await act(async () => {
24+
root.render(
25+
React.createElement(NLEPreview, {
26+
projectId: "p",
27+
iframeRef: createRef<HTMLIFrameElement>(),
28+
onIframeLoad: () => {},
29+
previewSlots: [
30+
{ gen: 0, role: "live" },
31+
{ gen: 1, role: "shadow", url: "/api/projects/p/preview?_t=1" },
32+
],
33+
onShadowIframeLoad: () => {},
34+
onShadowReadyChange: () => {},
35+
onShadowError,
36+
setShadowIframeNode: () => {},
37+
resetPreviewSlots: () => {},
38+
}),
39+
);
40+
});
41+
42+
const shadow = host.querySelectorAll<
43+
HTMLElement & { iframeElement: HTMLIFrameElement; ready: boolean }
44+
>("hyperframes-player")[1];
45+
const iframe = shadow.iframeElement;
46+
act(() => {
47+
iframe.dispatchEvent(new Event("load"));
48+
});
49+
act(() => {
50+
vi.advanceTimersByTime(10_000);
51+
});
52+
expect(onShadowError).not.toHaveBeenCalled();
53+
54+
Object.assign(iframe.contentWindow!, { __player: { getDuration: () => 9 } });
55+
act(() => {
56+
vi.advanceTimersByTime(1_000);
57+
});
58+
expect(onShadowError).not.toHaveBeenCalled();
59+
expect(shadow.ready).toBe(true);
60+
// The live slot keeps the player's own 8 s deadline.
61+
expect(host.querySelector("hyperframes-player")?.hasAttribute("no-load-timeout")).toBe(false);
62+
act(() => root.unmount());
63+
});

‎packages/studio/src/components/nle/NLEPreview.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,7 @@ export const NLEPreview = memo(function NLEPreview({
209209

210210
const updateCompositionSizeFromPreview = useCallback(() => {
211211
const next = readPreviewCompositionSize(previewIframeRef.current);
212-
// Pure updater — the parent notification happens in the effect below
213-
// (updaters may run more than once under Strict Mode / concurrent React).
212+
// Pure updater; the effect below notifies the parent (Strict Mode may run updaters twice).
214213
setCompositionSize((prev) =>
215214
prev?.width === next?.width && prev?.height === next?.height ? prev : next,
216215
);
@@ -563,6 +562,7 @@ export const NLEPreview = memo(function NLEPreview({
563562
onPreviewError={(message) => onShadowError(slot.gen, message)}
564563
portrait={portrait}
565564
suppressLoadingOverlay
565+
noLoadTimeout
566566
style={SHADOW_IFRAME_STYLE}
567567
/>
568568
),

‎packages/studio/src/player/components/Player.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ interface PlayerProps {
3737
portrait?: boolean;
3838
style?: React.CSSProperties;
3939
suppressLoadingOverlay?: boolean;
40+
noLoadTimeout?: boolean;
4041
}
4142

4243
interface HyperframesPlayerElement extends HTMLElement {
@@ -154,6 +155,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
154155
portrait,
155156
style,
156157
suppressLoadingOverlay,
158+
noLoadTimeout,
157159
},
158160
ref,
159161
) => {
@@ -224,6 +226,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
224226
setCompositionLoading(true);
225227
loadStartedAtRef.current = performance.now();
226228
loadIdRef.current += 1;
229+
player.removeAttribute("no-load-timeout");
227230
player.setAttribute("src", retryUrl.pathname + retryUrl.search);
228231
};
229232
retryPreviewRef.current = retryPreview;
@@ -331,6 +334,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
331334
player.setAttribute("shader-capture-scale", "1");
332335
player.setAttribute("shader-loading", "player");
333336
player.setAttribute("low-power-idle", "");
337+
player.toggleAttribute("no-load-timeout", noLoadTimeout === true);
334338
player.setAttribute("width", String(portrait ? 1080 : 1920));
335339
player.setAttribute("height", String(portrait ? 1920 : 1080));
336340
player.style.width = "100%";

‎packages/studio/src/player/hooks/useShadowPreviewReload.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,12 @@ function isDocumentHidden(): boolean {
4949

5050
type UseShadowPreviewReloadParams = Omit<
5151
UseTimelineSyncCallbacksParams,
52-
"probeIntervalRef" | "onAdapterReady" | "getAdapter" | "isCurrent" | "onLoadGiveUp"
52+
| "probeIntervalRef"
53+
| "onAdapterReady"
54+
| "getAdapter"
55+
| "isCurrent"
56+
| "onLoadGiveUp"
57+
| "hostOwnsDeadline"
5358
> & {
5459
getAdapter: (overrideIframe?: HTMLIFrameElement | null) => PlaybackAdapter | null;
5560
/** Runs right after a shadow becomes the live iframe (iframeRef already points at it). */
@@ -249,6 +254,7 @@ export function useShadowPreviewReload({
249254
applyPreviewAudioState: () => {},
250255
onAdapterReady: markAdapterReady,
251256
isCurrent: isCurrentShadow,
257+
hostOwnsDeadline: true,
252258
});
253259
cancelPendingLoadRef.current = cancelPendingLoad;
254260

‎packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,31 @@ describe("shadow reload readiness and failure", () => {
461461
unmount(root);
462462
});
463463

464+
it("promotes a shadow whose runtime boots after Studio's own 5 s readiness wait", () => {
465+
vi.useFakeTimers();
466+
const onPreviewReloadFailed = vi.fn();
467+
const { getApi, gen, root } = beginReload({ onPreviewReloadFailed });
468+
const { win } = makeAdapterWindow();
469+
const booting: Record<string, unknown> = { ...win, __player: undefined };
470+
const iframe = makeFakeIframe(booting);
471+
act(() => {
472+
getApi().setShadowIframeNode(iframe);
473+
getApi().onShadowIframeLoad(gen);
474+
});
475+
476+
act(() => void vi.advanceTimersByTime(10_000));
477+
booting.__player = win.__player;
478+
act(() => {
479+
window.dispatchEvent(
480+
new MessageEvent("message", { data: { source: "hf-preview", type: "state" } }),
481+
);
482+
getApi().onShadowReadyChange(gen, true);
483+
});
484+
expect(getApi().iframeRef.current).toBe(iframe);
485+
expect(onPreviewReloadFailed).not.toHaveBeenCalled();
486+
unmount(root);
487+
});
488+
464489
it("gives a reload that replaces a slow one its own full set of budgets", () => {
465490
vi.useFakeTimers();
466491
vi.spyOn(console, "error").mockImplementation(() => {});

0 commit comments

Comments
 (0)