From 85b0d119f8aa198c6d939131a381ee206884b670 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 17 Sep 2026 10:09:37 +0100 Subject: [PATCH 1/2] fix(map-renderer): only count visible time against the render timeout A hidden tab pauses requestAnimationFrame and with it MapLibre's render loop, so a long export used to fail with a timeout if the user switched apps. The timeout now pauses while the document is hidden and resumes on return. --- e2e/map-renderer.test.ts | 50 ++++++++++++++++++++++++++++++++ src/lib/map-renderer/README.md | 6 ++-- src/lib/map-renderer/index.ts | 52 +++++++++++++++++++++++----------- 3 files changed, 89 insertions(+), 19 deletions(-) diff --git a/e2e/map-renderer.test.ts b/e2e/map-renderer.test.ts index fd44482..9f385b0 100644 --- a/e2e/map-renderer.test.ts +++ b/e2e/map-renderer.test.ts @@ -326,6 +326,56 @@ describeEngines((engine) => { expect(Date.now() - started).toBeLessThan(10_000); }); + test("a hidden tab pauses the render timeout", async () => { + const v = fitBounds(VIEW_BBOX, 256, 256); + const [tile] = tileGrid(v, TILE_SIZE); + // Serialized by hand: StyleSpecification is too deep for evaluate's arg typing. + const result = await page.evaluate(async (json: string) => { + const { style, tile, zoom } = JSON.parse(json); + 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)); + const renderer = await window.mapPrinter.createMapRenderer({ + style, + pixelRatio: 1, + tileSize: { width: 256, height: 256 }, + channels: 3, + renderTimeoutMs: 1_000, + }); + try { + setVisibility("hidden"); + let settled = false; + const rendering = renderer + .render(tile, zoom) + .catch((err: Error) => err.message) + .finally(() => (settled = true)); + await sleep(2_000); + const settledWhileHidden = settled; + setVisibility("visible"); + const t0 = performance.now(); + const message = await rendering; + return { settledWhileHidden, message, visibleMs: performance.now() - t0 }; + } finally { + renderer.destroy(); + setVisibility("visible"); + delete (document as { visibilityState?: unknown }).visibilityState; + } + }, JSON.stringify({ style: hangingStyle, tile, zoom: v.zoom })); + + 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 { From cc4a0be5288a9d4c942d948e27ea39c964cd2eca Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 17 Sep 2026 10:17:15 +0100 Subject: [PATCH 2/2] test(e2e): exercise the hidden-tab timeout with a slow style, not a hung tile WebKit keeps a hung request's connection open after the map is destroyed, so a second hanging-tile test starved the requests of the tests after it in CI. --- e2e/map-renderer.test.ts | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/e2e/map-renderer.test.ts b/e2e/map-renderer.test.ts index 9f385b0..6e32e49 100644 --- a/e2e/map-renderer.test.ts +++ b/e2e/map-renderer.test.ts @@ -327,11 +327,10 @@ describeEngines((engine) => { }); test("a hidden tab pauses the render timeout", async () => { - const v = fitBounds(VIEW_BBOX, 256, 256); - const [tile] = tileGrid(v, TILE_SIZE); - // Serialized by hand: StyleSpecification is too deep for evaluate's arg typing. - const result = await page.evaluate(async (json: string) => { - const { style, tile, zoom } = JSON.parse(json); + // 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, @@ -343,32 +342,36 @@ describeEngines((engine) => { }; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - const renderer = await window.mapPrinter.createMapRenderer({ - style, - pixelRatio: 1, - tileSize: { width: 256, height: 256 }, - channels: 3, - renderTimeoutMs: 1_000, - }); try { setVisibility("hidden"); let settled = false; - const rendering = renderer - .render(tile, zoom) - .catch((err: Error) => err.message) + 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 rendering; + const message = await creating; return { settledWhileHidden, message, visibleMs: performance.now() - t0 }; } finally { - renderer.destroy(); setVisibility("visible"); delete (document as { visibilityState?: unknown }).visibilityState; } - }, JSON.stringify({ style: hangingStyle, tile, zoom: v.zoom })); + }); expect(result.settledWhileHidden).toBe(false); expect(result.message).toMatch(/did not finish rendering within 1000 ms/);