diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 869531152a..7e2a0fbc14 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -796,6 +796,9 @@ // recordSample to coalesce display-rate events onto authored frames; // the import and helper insertion shift the untouched functions' lines. "packages/studio/src/hooks/useGestureRecording.ts", + // useShadowPreviewReload.ts: the reload hook is inherited large React control flow; this change only wires in + // useShadowReloadRetry (no new branch here), and touching the file makes fallow report the whole hook. + "packages/studio/src/player/hooks/useShadowPreviewReload.ts", // sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations / // patch*InTag pre-date this PR; only the PatchOperation type gained two // optional fields, but the line-shift fingerprint re-flags the inherited diff --git a/packages/studio/src/player/hooks/useShadowPreviewReload.ts b/packages/studio/src/player/hooks/useShadowPreviewReload.ts index a4a8697bad..8cd41c9ff9 100644 --- a/packages/studio/src/player/hooks/useShadowPreviewReload.ts +++ b/packages/studio/src/player/hooks/useShadowPreviewReload.ts @@ -23,6 +23,7 @@ import { setPreviewReloading } from "../previewReloading"; import { announcePreviewPromoted } from "../sceneSwap"; import { afterStudioPendingEdits, isStudioEditSaving } from "../../utils/studioPendingEdits"; import { usePlayerStore } from "../store/playerStore"; +import { READY_TIMEOUT_CAUSE, useShadowReloadRetry } from "./useShadowReloadRetry"; // One 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. @@ -117,6 +118,7 @@ export function useShadowPreviewReload({ const shadowUrlRef = useRef(""); const savesAtRequestRef = useRef(0); const beginShadowReloadRef = useRef<(url: string) => void>(() => {}); + const retry = useShadowReloadRetry(shadowGenRef, shadowUrlRef, beginShadowReloadRef); const promoteWhenReadyRef = useRef<(gen: number) => void>(() => {}); const [previewSlots, setPreviewSlots] = useState([{ gen: 0, role: "live" }]); @@ -139,6 +141,7 @@ export function useShadowPreviewReload({ : "its loaders to clear"; const got = pageGotTo(shadowIframeRef.current); shadowGenRef.current += 1; + const failedGen = shadowGenRef.current; stopPendingShadow(); shadowIframeRef.current = null; isRefreshingRef.current = false; @@ -151,8 +154,9 @@ export function useShadowPreviewReload({ const pageMissingNow = refused !== null && refused < 500; (pageMissingNow ? console.warn : console.error)(`[studio] ${message}`); onReloadFailedRef.current?.(message); + retry.afterFailure(cause, failedGen); }, - [stopPendingShadow, isRefreshingRef, pendingSeekRef], + [stopPendingShadow, retry, isRefreshingRef, pendingSeekRef], ); const gestureSavesOnScreen = useCallback(() => { @@ -313,7 +317,7 @@ export function useShadowPreviewReload({ armReadyTimerRef.current(gen); return; } - failShadow(gen, "it took too long to load"); + failShadow(gen, READY_TIMEOUT_CAUSE); }, SHADOW_READY_TIMEOUT_MS); }, [failShadow], @@ -323,6 +327,7 @@ export function useShadowPreviewReload({ const beginShadowReload = useCallback( (url: string) => { + retry.started(); shadowGenRef.current += 1; const gen = shadowGenRef.current; stopPendingShadow(); @@ -335,12 +340,13 @@ export function useShadowPreviewReload({ // Thumbnails of the edit wait for the new preview instead of competing with it. setPreviewReloading(true); }, - [stopPendingShadow, armReadyTimer, gestureSavesOnScreen], + [retry, stopPendingShadow, armReadyTimer, gestureSavesOnScreen], ); beginShadowReloadRef.current = beginShadowReload; // Composition switch (not an edit reload): drop any in-flight shadow. const resetPreviewSlots = useCallback(() => { + retry.cancel(); shadowGenRef.current += 1; stopPendingShadow(); shadowIframeRef.current = null; @@ -349,7 +355,7 @@ export function useShadowPreviewReload({ usePlayerStore.getState().setTimelineReady(false); setPreviewSlots(planShadowDiscard); setPreviewReloading(false); - }, [stopPendingShadow, isRefreshingRef, pendingSeekRef]); + }, [retry, stopPendingShadow, isRefreshingRef, pendingSeekRef]); // Hiding the tab pauses the budget; showing it again restarts the full budget for a pending shadow. useMountEffect(() => { @@ -364,6 +370,7 @@ export function useShadowPreviewReload({ document.addEventListener("visibilitychange", onVisibilityChange); return () => { document.removeEventListener("visibilitychange", onVisibilityChange); + retry.cancel(); stopPendingShadow(); setPreviewReloading(false); }; diff --git a/packages/studio/src/player/hooks/useShadowReloadRetry.ts b/packages/studio/src/player/hooks/useShadowReloadRetry.ts new file mode 100644 index 0000000000..ea8e646f30 --- /dev/null +++ b/packages/studio/src/player/hooks/useShadowReloadRetry.ts @@ -0,0 +1,68 @@ +import { useCallback, useMemo, useRef, type RefObject } from "react"; + +/** What a shadow reload gives as its cause when it ran out its wait budgets. */ +export const READY_TIMEOUT_CAUSE = "it took too long to load"; +export const SHADOW_RETRY_DELAY_MS = 2_000; + +/** One more try for a reload that timed out, so an edit is not left unseen behind the old preview. A newer reload or + * a composition switch in the meantime cancels it, and a retry that times out is not tried again. */ +export function useShadowReloadRetry( + generation: RefObject, + url: RefObject, + begin: RefObject<(url: string) => void>, +) { + const retried = useRef(false); + const retrying = useRef(false); + const cancelRef = useRef(() => {}); + + const retryWhenVisible = useCallback( + (failedGen: number) => { + const schedule = () => { + const timer = setTimeout(() => { + cancelRef.current = () => {}; + if (generation.current !== failedGen) return; + const next = new URL(url.current, window.location.origin); + next.searchParams.set("_t", String(Date.now())); + retrying.current = true; + begin.current(next.toString()); + }, SHADOW_RETRY_DELAY_MS); + cancelRef.current = () => { + clearTimeout(timer); + cancelRef.current = () => {}; + }; + }; + if (document.visibilityState !== "hidden") return schedule(); + const onVisible = () => { + if (document.visibilityState === "hidden") return; + document.removeEventListener("visibilitychange", onVisible); + schedule(); + }; + document.addEventListener("visibilitychange", onVisible); + cancelRef.current = () => { + document.removeEventListener("visibilitychange", onVisible); + cancelRef.current = () => {}; + }; + }, + [generation, url, begin], + ); + + /** A reload failed with `cause` as generation `failedGen`: a timeout is retried once. */ + const afterFailure = useCallback( + (cause: string, failedGen: number) => { + if (cause !== READY_TIMEOUT_CAUSE || retried.current) return; + retried.current = true; + cancelRef.current(); + retryWhenVisible(failedGen); + }, + [retryWhenVisible], + ); + /** A reload starts: a request of its own gets its own retry; the retry itself does not. */ + const started = useCallback(() => { + cancelRef.current(); + if (!retrying.current) retried.current = false; + retrying.current = false; + }, []); + /** The pending retry, if any, is dropped. */ + const cancel = useCallback(() => cancelRef.current(), []); + return useMemo(() => ({ afterFailure, started, cancel }), [afterFailure, started, cancel]); +} diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.shadowRetry.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.shadowRetry.test.ts new file mode 100644 index 0000000000..99da1dbbeb --- /dev/null +++ b/packages/studio/src/player/hooks/useTimelinePlayer.shadowRetry.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom +// A reload that ran out its budgets is tried once more, so an edit is not left unseen behind the old preview. + +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SHADOW_READY_BUDGETS, SHADOW_READY_TIMEOUT_MS } from "./useShadowPreviewReload"; +import { SHADOW_RETRY_DELAY_MS } from "./useShadowReloadRetry"; +import { + makeAdapterWindow, + makeFakeIframe, + renderTimelinePlayerHarness, + resetPlayerStore, +} from "./timelinePlayerTestHarness"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const FULL_WAIT_MS = SHADOW_READY_TIMEOUT_MS * SHADOW_READY_BUDGETS; + +vi.mock("../../utils/gsapSoftReload", async (importOriginal) => ({ + ...(await importOriginal()), + ensureMotionPathPluginLoaded: vi.fn(), +})); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + document.body.innerHTML = ""; + resetPlayerStore(); +}); + +function beginReload(onPreviewReloadFailed = vi.fn()) { + vi.useFakeTimers(); + vi.spyOn(console, "error").mockImplementation(() => {}); + const harness = renderTimelinePlayerHarness({ onPreviewReloadFailed }); + const live = makeFakeIframe(makeAdapterWindow().win); + act(() => { + harness.getApi().iframeRef.current = live; + harness.getApi().onIframeLoad(); + }); + act(() => harness.getApi().refreshPlayer()); + return { ...harness, onPreviewReloadFailed }; +} + +const shadows = (slots: { role: string; url?: string; gen: number }[]) => + slots.filter((slot) => slot.role === "shadow"); + +describe("a shadow reload that timed out", () => { + it("is loaded once more after a short wait, with a fresh address", () => { + const { getApi, onPreviewReloadFailed } = beginReload(); + const first = shadows(getApi().previewSlots)[0]!; + + act(() => void vi.advanceTimersByTime(FULL_WAIT_MS)); + expect(onPreviewReloadFailed).toHaveBeenCalledTimes(1); + expect(shadows(getApi().previewSlots)).toHaveLength(0); + + act(() => void vi.advanceTimersByTime(SHADOW_RETRY_DELAY_MS)); + const again = shadows(getApi().previewSlots); + expect(again).toHaveLength(1); + expect(again[0]!.gen).toBeGreaterThan(first.gen); + expect(new URL(again[0]!.url!, "http://x").pathname).toBe( + new URL(first.url!, "http://x").pathname, + ); + }); + + it("is not tried a third time when the retry also runs out its budgets", () => { + const { getApi, onPreviewReloadFailed } = beginReload(); + act(() => void vi.advanceTimersByTime(FULL_WAIT_MS + SHADOW_RETRY_DELAY_MS)); + expect(shadows(getApi().previewSlots)).toHaveLength(1); + + act(() => void vi.advanceTimersByTime(FULL_WAIT_MS + SHADOW_RETRY_DELAY_MS * 3)); + expect(onPreviewReloadFailed).toHaveBeenCalledTimes(2); + expect(shadows(getApi().previewSlots)).toHaveLength(0); + }); + + it("is not retried when a newer reload has taken over during the wait", () => { + const { getApi } = beginReload(); + act(() => void vi.advanceTimersByTime(FULL_WAIT_MS)); + act(() => getApi().refreshPlayer()); + const newer = shadows(getApi().previewSlots); + expect(newer).toHaveLength(1); + + act(() => void vi.advanceTimersByTime(SHADOW_RETRY_DELAY_MS)); + expect(shadows(getApi().previewSlots)).toEqual(newer); + }); +});