mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-04 04:46:59 +00:00
fix(studio): a preview reload that timed out is tried once more, so an edit is not left unseen (#6953)
* fix(studio): a preview reload that timed out is tried once more, so an edit is not left unseen * fix(studio): trim the retry hook's comments to the package's share
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<PreviewIframeSlot[]>([{ 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);
|
||||
};
|
||||
|
||||
@@ -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<number>,
|
||||
url: RefObject<string>,
|
||||
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]);
|
||||
}
|
||||
@@ -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<typeof import("../../utils/gsapSoftReload")>()),
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user