mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio): don't fail a shadow reload while the tab is hidden (#4292)
The shadow reload promotes on frame-driven readiness (the player's paint-and-idle, then two requestAnimationFrames) but failed on a wall-clock 15s setTimeout. A hidden tab renders no frames, so every external edit made while Studio was in the background failed with "it took too long to load", the toasts stacked, and the stale preview stayed up even after returning to the tab. The budget now only runs while the document is visible: hiding the tab clears the timer, and showing it again restarts the full budget for the pending shadow, which then promotes normally. Fixes #4290 — Rames Jusso Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0945de83c7
commit
16959deeac
@@ -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<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
// The shadow still owed a wait budget, so a hidden tab can resume it when it becomes visible.
|
||||
const budgetGenRef = useRef<number | null>(null);
|
||||
const cancelPendingLoadRef = useRef<() => void>(() => {});
|
||||
const [previewSlots, setPreviewSlots] = useState<PreviewIframeSlot[]>([{ 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,
|
||||
|
||||
@@ -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<typeof createRoot>) {
|
||||
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"));
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user