diff --git a/public/boot.js b/public/boot.js index 240015eb6..fee10a7b3 100644 --- a/public/boot.js +++ b/public/boot.js @@ -142,11 +142,30 @@ window.removeEventListener("unhandledrejection", onRejection); window.removeEventListener("openidea:boot-error", onBootError); window.removeEventListener("openidea:boot-ready", onReady); + document.removeEventListener("visibilitychange", onVisibility); }; - const timer = window.setTimeout( - () => show(rejection === undefined ? text.timeout : `${text.timeout}\n\nUnhandled rejection: ${rejection}`), - 30_000, - ); + // The timeout counts only while the window shows: a hidden or covered window gets no + // animation frames, and the splash waits for those to finish startup. + let remaining = 30_000; + let armedAt = 0; + let timer; + const arm = () => { + if (failed || ready || timer !== undefined || document.hidden) return; + armedAt = Date.now(); + timer = window.setTimeout( + () => show(rejection === undefined ? text.timeout : `${text.timeout}\n\nUnhandled rejection: ${rejection}`), + remaining, + ); + }; + const onVisibility = () => { + if (!document.hidden) return arm(); + if (timer === undefined) return; + window.clearTimeout(timer); + timer = undefined; + remaining -= Date.now() - armedAt; + }; + arm(); + document.addEventListener("visibilitychange", onVisibility); window.addEventListener("error", onError, true); window.addEventListener("unhandledrejection", onRejection); window.addEventListener("openidea:boot-error", onBootError); diff --git a/src/platform/boot.test.ts b/src/platform/boot.test.ts index 9b3a02d90..e87d620f6 100644 --- a/src/platform/boot.test.ts +++ b/src/platform/boot.test.ts @@ -95,6 +95,27 @@ it("reports the first rejection when startup then times out", () => { expect(details).not.toContain("later rejection"); }); +it("counts the startup timeout only while the window shows", () => { + const hidden = (value: boolean) => { + Object.defineProperty(document, "hidden", { configurable: true, value }); + document.dispatchEvent(new Event("visibilitychange")); + }; + try { + vi.advanceTimersByTime(20_000); + // Behind another window: no frames, so the splash can't finish; that isn't a failure. + hidden(true); + vi.advanceTimersByTime(60_000); + expect(document.getElementById("boot-error")).toBeNull(); + hidden(false); + vi.advanceTimersByTime(9_000); + expect(document.getElementById("boot-error")).toBeNull(); + vi.advanceTimersByTime(1_000); + expect(document.querySelector("textarea")?.value).toContain("Startup did not finish within 30 seconds."); + } finally { + Object.defineProperty(document, "hidden", { configurable: true, value: false }); + } +}); + it("preserves the first failure", () => { window.dispatchEvent(new ErrorEvent("error", { error: new Error("startup failed") })); window.dispatchEvent(new ErrorEvent("error", { message: "later failure" }));