Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions e2e/map-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions src/lib/map-renderer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`

Expand Down
52 changes: 35 additions & 17 deletions src/lib/map-renderer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,17 +136,16 @@ export async function createMapRenderer(
const gl = requireWebGl2(canvas);

await new Promise<void>((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();
});
});
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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<typeof setTimeout> | 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 {
Expand Down