diff --git a/packages/studio/src/player/hooks/useShadowPreviewReload.ts b/packages/studio/src/player/hooks/useShadowPreviewReload.ts index 0e01d6d18f..b813ef497a 100644 --- a/packages/studio/src/player/hooks/useShadowPreviewReload.ts +++ b/packages/studio/src/player/hooks/useShadowPreviewReload.ts @@ -16,8 +16,13 @@ import type { PlaybackAdapter } from "../lib/playbackTypes"; // The single wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade // leaves about 6.5s for the document load and runtime boot. Nothing shorter may fail the swap. +// It only runs while the tab is visible: readiness is frame-driven, and a hidden tab renders none. export const SHADOW_READY_TIMEOUT_MS = 15_000; +function isDocumentHidden(): boolean { + return typeof document !== "undefined" && document.visibilityState === "hidden"; +} + type UseShadowPreviewReloadParams = Omit< UseTimelineSyncCallbacksParams, "probeIntervalRef" | "onAdapterReady" | "getAdapter" | "isCurrent" | "onLoadGiveUp" @@ -55,11 +60,14 @@ export function useShadowPreviewReload({ const onReloadFailedRef = useRef(onReloadFailed); onReloadFailedRef.current = onReloadFailed; const readyTimerRef = useRef | undefined>(undefined); + // The shadow still owed a wait budget, so a hidden tab can resume it when it becomes visible. + const budgetGenRef = useRef(null); const cancelPendingLoadRef = useRef<() => void>(() => {}); const [previewSlots, setPreviewSlots] = useState([{ gen: 0, role: "live" }]); const stopPendingShadow = useCallback(() => { clearTimeout(readyTimerRef.current); + budgetGenRef.current = null; cancelPendingLoadRef.current(); pendingCommitRef.current = null; visuallyReadyGenRef.current = null; @@ -145,18 +153,30 @@ export function useShadowPreviewReload({ shadowIframeRef.current = node; }, []); + const armReadyTimer = useCallback( + (gen: number) => { + clearTimeout(readyTimerRef.current); + if (isDocumentHidden()) return; + readyTimerRef.current = setTimeout( + () => failShadow(gen, "it took too long to load"), + SHADOW_READY_TIMEOUT_MS, + ); + }, + [failShadow], + ); + const armReadyTimerRef = useRef(armReadyTimer); + armReadyTimerRef.current = armReadyTimer; + const beginShadowReload = useCallback( (url: string) => { shadowGenRef.current += 1; const gen = shadowGenRef.current; stopPendingShadow(); - readyTimerRef.current = setTimeout( - () => failShadow(gen, "it took too long to load"), - SHADOW_READY_TIMEOUT_MS, - ); + budgetGenRef.current = gen; + armReadyTimer(gen); setPreviewSlots((prev) => planShadowReload(prev, gen, url)); }, - [stopPendingShadow, failShadow], + [stopPendingShadow, armReadyTimer], ); // Composition switch (not an edit reload): drop any in-flight shadow. @@ -169,7 +189,22 @@ export function useShadowPreviewReload({ setPreviewSlots(planShadowDiscard); }, [stopPendingShadow, isRefreshingRef, pendingSeekRef]); - useMountEffect(() => stopPendingShadow); + // Hiding the tab pauses the budget; showing it again restarts the full budget for a pending shadow. + useMountEffect(() => { + const onVisibilityChange = () => { + if (isDocumentHidden()) { + clearTimeout(readyTimerRef.current); + return; + } + const gen = budgetGenRef.current; + if (gen != null && gen === shadowGenRef.current) armReadyTimerRef.current(gen); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", onVisibilityChange); + stopPendingShadow(); + }; + }); return { previewSlots, diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts index 25439e3dc9..7fa37aa5d2 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts @@ -289,6 +289,50 @@ describe("shadow reload readiness and failure", () => { unmount(root); }); + it("does not spend the budget while the tab is hidden, and promotes once it is visible", () => { + vi.useFakeTimers(); + const onPreviewReloadFailed = vi.fn(); + const setVisibility = stubVisibility("hidden"); + const { getApi, gen, root } = beginReload({ onPreviewReloadFailed }); + const shadow = makeShadowWithSpies(); + act(() => { + getApi().setShadowIframeNode(shadow.iframe); + getApi().onShadowIframeLoad(gen); + }); + + // A hidden tab renders no frames, so readiness cannot arrive; the wall clock must not fail it. + act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS * 3)); + expect(onPreviewReloadFailed).not.toHaveBeenCalled(); + expect(getApi().previewSlots).toHaveLength(2); + + act(() => setVisibility("visible")); + act(() => getApi().onShadowReadyChange(gen, true)); + expect(getApi().iframeRef.current).toBe(shadow.iframe); + expect(onPreviewReloadFailed).not.toHaveBeenCalled(); + unmount(root); + }); + + it("restarts the full budget when a hidden tab becomes visible, then fails a shadow that stays silent", () => { + vi.useFakeTimers(); + vi.spyOn(console, "error").mockImplementation(() => {}); + const onPreviewReloadFailed = vi.fn(); + const setVisibility = stubVisibility("visible"); + const { getApi, live, root } = beginReload({ onPreviewReloadFailed }); + + act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS - 1000)); + act(() => setVisibility("hidden")); + act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS)); + expect(onPreviewReloadFailed).not.toHaveBeenCalled(); + + act(() => setVisibility("visible")); + act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS - 1)); + expect(onPreviewReloadFailed).not.toHaveBeenCalled(); + act(() => void vi.advanceTimersByTime(1)); + expect(onPreviewReloadFailed).toHaveBeenCalledWith(expect.stringContaining("too long")); + expect(getApi().iframeRef.current).toBe(live); + unmount(root); + }); + it("drops a shadow whose document reports an error and reports the cause", () => { vi.spyOn(console, "error").mockImplementation(() => {}); const onPreviewReloadFailed = vi.fn(); @@ -455,3 +499,12 @@ function unmount(root: ReturnType) { root.unmount(); }); } + +function stubVisibility(initial: DocumentVisibilityState) { + let state = initial; + vi.spyOn(document, "visibilityState", "get").mockImplementation(() => state); + return (next: DocumentVisibilityState) => { + state = next; + document.dispatchEvent(new Event("visibilitychange")); + }; +}