diff --git a/e2e/map-renderer.test.ts b/e2e/map-renderer.test.ts index fd44482..6e32e49 100644 --- a/e2e/map-renderer.test.ts +++ b/e2e/map-renderer.test.ts @@ -326,6 +326,59 @@ describeEngines((engine) => { expect(Date.now() - started).toBeLessThan(10_000); }); + test("a hidden tab pauses the render timeout", async () => { + // A slow style rather than a hanging tile source: WebKit keeps a hung + // request's connection after the map is destroyed, and a second one would + // starve the tests that follow. + const result = await page.evaluate(async () => { + let state: DocumentVisibilityState = "visible"; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => state, + }); + const setVisibility = (next: DocumentVisibilityState) => { + state = next; + document.dispatchEvent(new Event("visibilitychange")); + }; + const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + try { + setVisibility("hidden"); + let settled = false; + const creating = window.mapPrinter + .createMapRenderer({ + style: "/fixtures/style-geojson.json?delay=6000", + pixelRatio: 1, + tileSize: { width: 256, height: 256 }, + channels: 3, + renderTimeoutMs: 1_000, + }) + .then( + (renderer) => { + renderer.destroy(); + return "resolved"; + }, + (err: Error) => err.message, + ) + .finally(() => (settled = true)); + await sleep(2_000); + const settledWhileHidden = settled; + setVisibility("visible"); + const t0 = performance.now(); + const message = await creating; + return { settledWhileHidden, message, visibleMs: performance.now() - t0 }; + } finally { + setVisibility("visible"); + delete (document as { visibilityState?: unknown }).visibilityState; + } + }); + + expect(result.settledWhileHidden).toBe(false); + expect(result.message).toMatch(/did not finish rendering within 1000 ms/); + expect(result.visibleMs).toBeGreaterThanOrEqual(900); + expect(result.visibleMs).toBeLessThan(5_000); + }); + test("rejects when the style cannot be loaded", async () => { const v = fitBounds(VIEW_BBOX, 256, 256); const [tile] = tileGrid(v, TILE_SIZE); diff --git a/src/lib/map-renderer/README.md b/src/lib/map-renderer/README.md index 3504e13..68433a2 100644 --- a/src/lib/map-renderer/README.md +++ b/src/lib/map-renderer/README.md @@ -90,8 +90,10 @@ gets, because plenty of MapLibre errors leave a map that renders correctly: map rendering. A render that does not reach `idle` within `renderTimeoutMs` rejects with a -timeout error that names the tile. `document.visibilityState === "hidden"` -stalls MapLibre's render loop, so the message says so when the tab is hidden. +timeout error that names the tile. Only time spent with the document visible +counts: a hidden tab pauses `requestAnimationFrame` and with it MapLibre's +render loop, so switching away mid-export pauses the export rather than +failing it. ### `maxTileSize(pixelRatio)` diff --git a/src/lib/map-renderer/index.ts b/src/lib/map-renderer/index.ts index 210d514..71806fd 100644 --- a/src/lib/map-renderer/index.ts +++ b/src/lib/map-renderer/index.ts @@ -136,17 +136,16 @@ export async function createMapRenderer( const gl = requireWebGl2(canvas); await new Promise((resolve, reject) => { - const timer = setTimeout( - () => fail(timeoutError("The style", renderTimeoutMs)), - renderTimeoutMs, + const clearTimer = foregroundTimeout(renderTimeoutMs, () => + fail(timeoutError("The style", renderTimeoutMs)), ); const fail = (error: Error) => { - clearTimeout(timer); + clearTimer(); reject(error); }; rejectPending = fail; map.once("style.load", () => { - clearTimeout(timer); + clearTimer(); resolve(); }); }); @@ -194,15 +193,11 @@ export async function createMapRenderer( } resolve(); }; - const timer = setTimeout( - () => - fail( - timeoutError(`Tile ${tile.col},${tile.row}`, renderTimeoutMs), - ), - renderTimeoutMs, + const clearTimer = foregroundTimeout(renderTimeoutMs, () => + fail(timeoutError(`Tile ${tile.col},${tile.row}`, renderTimeoutMs)), ); const finish = () => { - clearTimeout(timer); + clearTimer(); map.off("idle", onIdle); rejectPending = undefined; }; @@ -280,11 +275,34 @@ function describeError(event: ErrorEvent & { sourceId?: string }): Error { } function timeoutError(what: string, ms: number): Error { - const hidden = - document.visibilityState === "hidden" - ? " (the tab is hidden, which pauses map rendering)" - : ""; - return new Error(`${what} did not finish rendering within ${ms} ms${hidden}`); + return new Error(`${what} did not finish rendering within ${ms} ms`); +} + +/** setTimeout that only counts time while the document is visible: MapLibre + * renders from requestAnimationFrame, which a hidden tab pauses. */ +function foregroundTimeout(ms: number, onTimeout: () => void): () => void { + let remaining = ms; + let startedAt = 0; + let timer: ReturnType | undefined; + const resume = () => { + if (timer !== undefined) return; + startedAt = Date.now(); + timer = setTimeout(onTimeout, remaining); + }; + const pause = () => { + if (timer === undefined) return; + clearTimeout(timer); + timer = undefined; + remaining -= Date.now() - startedAt; + }; + const onVisibility = () => + document.visibilityState === "hidden" ? pause() : resume(); + document.addEventListener("visibilitychange", onVisibility); + onVisibility(); + return () => { + pause(); + document.removeEventListener("visibilitychange", onVisibility); + }; } function requireWebGl2(canvas: HTMLCanvasElement): WebGL2RenderingContext {