From 3c67afeade8a89eaba7b989fc8ceec2bfd6dc895 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 26 Jul 2026 00:20:59 +0200 Subject: [PATCH] fix(offline): throttle the map tile prefetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prefetch dispatched every tile of a trip's bounding box in one synchronous loop — up to MAX_TILES per trip. A trip spanning a few countries enumerates thousands of tiles at zoom 10 alone, so logging in fired ~8k no-cors requests back to back and kept them coming for half a minute. All of them go through the Workbox fetch handler, which then has to write a cache entry plus an expiration record per tile, so everything the app itself requested queued up behind the burst and the UI just sat there. Run the queue through a small worker pool instead, and let it stop early when the user goes offline or logs out. Two things fall out of that: - Tiles already in Cache Storage are now skipped before the request is made, so a resumed or repeated prefetch over a warm cache is nearly free. - {s} has to be a pure function of the tile coordinates for that lookup to hit. The rotating counter gave the same tile a different host on every run, which also meant caching it up to four times. --- client/src/sync/tilePrefetcher.ts | 167 +++++++++++++---- client/tests/unit/sync/tilePrefetcher.test.ts | 174 ++++++++++++++++++ 2 files changed, 310 insertions(+), 31 deletions(-) diff --git a/client/src/sync/tilePrefetcher.ts b/client/src/sync/tilePrefetcher.ts index f40223924..a33247fdd 100644 --- a/client/src/sync/tilePrefetcher.ts +++ b/client/src/sync/tilePrefetcher.ts @@ -6,7 +6,13 @@ * 1. Compute bbox from trip's place coordinates + padding. * 2. For zooms 10–16, enumerate tile XYZ coordinates within bbox. * 3. Stop when cumulative tile estimate exceeds MAX_TILES (~50 MB). - * 4. Fetch each tile URL so the Service Worker CacheFirst handler caches it. + * 4. Fetch each tile URL so the Service Worker CacheFirst handler caches it, + * at most TILE_CONCURRENCY at a time. + * + * The throttle is the important part: a wide trip enumerates thousands of + * tiles, and dispatching them all at once buries every request the app itself + * makes behind them in the Service Worker's fetch queue — the UI then sits on + * a blank screen until the burst drains. * * Tile URL template format: Leaflet-compatible {z}/{x}/{y} with optional * {s} (subdomain) and {r} (retina suffix). @@ -14,6 +20,7 @@ import type { Place } from '../types' import { offlineDb, upsertSyncMeta } from '../db/offlineDb' +import { isAuthed } from './authGate' // ── Constants ───────────────────────────────────────────────────────────────── @@ -30,13 +37,32 @@ const AVG_TILE_KB = 15 */ export const MAX_TILES = Math.floor((180 * 1024) / AVG_TILE_KB) // = 12288 +/** + * Tile requests in flight at once. + * + * Low on purpose: these are background downloads competing with the live app + * for the Service Worker's fetch handler, the connection pool and the origin's + * IndexedDB queue (Workbox writes an expiration record per cached tile). + */ +export const TILE_CONCURRENCY = 6 + +/** Name of the Workbox runtime cache holding map tiles (see vite.config.js). */ +const TILE_CACHE = 'map-tiles' + const DEFAULT_TILE_URL = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' const SUBDOMAINS = ['a', 'b', 'c', 'd'] -let _subIdx = 0 -function nextSubdomain(): string { - return SUBDOMAINS[_subIdx++ % SUBDOMAINS.length] + +/** + * Pick the subdomain from the tile coordinates, the way Leaflet does. + * + * It has to be a pure function of x/y: a rotating counter hands the same tile a + * different host on every run, which both defeats the cache lookup below and + * stores the tile up to four times under four URLs. + */ +function subdomainFor(x: number, y: number): string { + return SUBDOMAINS[Math.abs(x + y) % SUBDOMAINS.length] } // ── Tile math ────────────────────────────────────────────────────────────────── @@ -126,10 +152,47 @@ export function buildTileUrl(template: string, z: number, x: number, y: number): .replace('{z}', String(z)) .replace('{x}', String(x)) .replace('{y}', String(y)) - .replace('{s}', nextSubdomain()) + .replace('{s}', subdomainFor(x, y)) .replace('{r}', '') } +/** + * Enumerate the tile coordinates to prefetch, lowest zoom first, stopping at + * the zoom level whose tiles would push the total past MAX_TILES. + */ +function enumerateTiles( + bbox: TileBbox, + minZoom: number, + maxZoom: number, +): Array<[z: number, x: number, y: number]> { + const coords: Array<[number, number, number]> = [] + + for (let z = minZoom; z <= maxZoom; z++) { + const minX = lngToTileX(bbox.minLng, z) + const maxX = lngToTileX(bbox.maxLng, z) + const minY = latToTileY(bbox.maxLat, z) + const maxY = latToTileY(bbox.minLat, z) + + if (coords.length + (maxX - minX + 1) * (maxY - minY + 1) > MAX_TILES) break + + for (let x = minX; x <= maxX; x++) { + for (let y = minY; y <= maxY; y++) coords.push([z, x, y]) + } + } + + return coords +} + +/** Open the tile cache, or null where Cache Storage isn't available. */ +async function openTileCache(): Promise { + try { + if (typeof caches === 'undefined') return null + return await caches.open(TILE_CACHE) + } catch { + return null + } +} + /** * Prefetch tiles for a bbox into the Service Worker cache. * Stops at the zoom level where the size cap would be exceeded. @@ -137,45 +200,54 @@ export function buildTileUrl(template: string, z: number, x: number, y: number): * - offline * - no active Service Worker (tiles won't be cached anyway) * - total tile count exceeds MAX_TILES before even starting zoom 10 + * + * Resolves once every tile has been dealt with, so callers that need the tiles + * on disk ("prepare for offline") can simply await it. Callers that don't + * (background sync) leave the promise floating. + * + * Returns the number of tiles actually fetched — tiles already in the cache are + * skipped without touching the network. */ export async function prefetchTiles( bbox: TileBbox, tileUrlTemplate: string, minZoom = 10, maxZoom = 16, - awaitAll = false, ): Promise { if (!navigator.onLine) return 0 if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return 0 + const coords = enumerateTiles(bbox, minZoom, maxZoom) + if (coords.length === 0) return 0 + + // Checking Cache Storage from here is far cheaper than letting the request + // reach the SW's CacheFirst handler, so a resumed or repeated prefetch over a + // warm cache costs almost nothing. + const cache = await openTileCache() + + let cursor = 0 let fetched = 0 - // When awaitAll is set (the "prepare for offline" path), we wait for every tile - // request to settle so the caller's progress bar only completes once the tiles - // are actually downloaded into the SW cache — not merely dispatched. - const inflight: Promise[] = [] - for (let z = minZoom; z <= maxZoom; z++) { - const minX = lngToTileX(bbox.minLng, z) - const maxX = lngToTileX(bbox.maxLng, z) - const minY = latToTileY(bbox.maxLat, z) - const maxY = latToTileY(bbox.minLat, z) - const count = (maxX - minX + 1) * (maxY - minY + 1) + async function worker(): Promise { + while (cursor < coords.length) { + // Going offline or logging out mid-run abandons the rest of the queue. + if (!navigator.onLine || !isAuthed()) return - if (fetched + count > MAX_TILES) break + const [z, x, y] = coords[cursor++] + const url = buildTileUrl(tileUrlTemplate, z, x, y) - for (let x = minX; x <= maxX; x++) { - for (let y = minY; y <= maxY; y++) { - const url = buildTileUrl(tileUrlTemplate, z, x, y) - // SW CacheFirst handler stores the response. Fire-and-forget unless the - // caller asked to await completion. - const p = fetch(url, { mode: 'no-cors' }).catch(() => {}) - if (awaitAll) inflight.push(p) - fetched++ - } + if (cache && (await cache.match(url))) continue + + // The SW CacheFirst handler stores the response. + await fetch(url, { mode: 'no-cors' }).catch(() => {}) + fetched++ } } - if (awaitAll && inflight.length) await Promise.allSettled(inflight) + await Promise.all( + Array.from({ length: Math.min(TILE_CONCURRENCY, coords.length) }, worker), + ) + return fetched } @@ -186,26 +258,59 @@ export async function prefetchTiles( */ export async function clearTileCache(): Promise { try { - if (typeof caches !== 'undefined') await caches.delete('map-tiles') + if (typeof caches !== 'undefined') await caches.delete(TILE_CACHE) } catch { /* Cache Storage unavailable (no SW / private mode) — nothing to clear */ } + + // Drop the recorded bboxes too, otherwise prefetchTilesForTrip would consider + // these trips done and never refill the cache we just emptied. + try { + const metas = await offlineDb.syncMeta.toArray() + await Promise.all( + metas + .filter(m => m.tilesBbox !== null) + .map(m => upsertSyncMeta({ ...m, tilesBbox: null })), + ) + } catch (err) { + console.error('[tilePrefetch] failed to reset tile bboxes:', err) + } +} + +/** Same bbox to within ~1 m — i.e. the trip's places haven't moved. */ +function sameBbox(a: [number, number, number, number], b: TileBbox): boolean { + return ( + Math.abs(a[0] - b.minLng) < 1e-5 && + Math.abs(a[1] - b.minLat) < 1e-5 && + Math.abs(a[2] - b.maxLng) < 1e-5 && + Math.abs(a[3] - b.maxLat) < 1e-5 + ) } /** * Full pipeline: compute bbox → guard → prefetch → update syncMeta. * Designed to be called fire-and-forget from tripSyncManager. + * + * Set `force` to prefetch even when this bbox was already covered by an earlier + * run — the "prepare for offline" path does, so the user gets a guarantee + * rather than a promise based on our own bookkeeping. */ export async function prefetchTilesForTrip( tripId: number, places: Place[], tileUrlTemplate?: string, - awaitAll = false, + force = false, ): Promise { const template = tileUrlTemplate || DEFAULT_TILE_URL const bbox = computeBbox(places) if (!bbox) return + // Unchanged bbox → the tiles are already there. Skipping outright keeps a + // routine login from re-walking thousands of tile URLs just to find them all + // cached. + const existing = await offlineDb.syncMeta.get(tripId) + if (!force && existing?.tilesBbox && sameBbox(existing.tilesBbox, bbox)) return + // Zoom-clamp rather than skip: prefetchTiles fills zooms low→high and stops // once MAX_TILES is reached, so large (region / road-trip) bboxes still get // their lower zooms cached instead of being skipped entirely. @@ -216,7 +321,7 @@ export async function prefetchTilesForTrip( // tile providers that don't send CORS headers. To stop the browser evicting // these tiles under the inflated quota, we request persistent storage at app // init instead (sync/persistentStorage.ts). - const fetched = await prefetchTiles(bbox, template, 10, 16, awaitAll) + const fetched = await prefetchTiles(bbox, template, 10, 16) // Update syncMeta with bbox and tile count const meta = await offlineDb.syncMeta.get(tripId) @@ -228,6 +333,6 @@ export async function prefetchTilesForTrip( } if (fetched > 0) { - console.info(`[tilePrefetch] trip ${tripId}: queued ${fetched} tiles for caching`) + console.info(`[tilePrefetch] trip ${tripId}: cached ${fetched} tiles`) } } diff --git a/client/tests/unit/sync/tilePrefetcher.test.ts b/client/tests/unit/sync/tilePrefetcher.test.ts index 768283850..2f051ad28 100644 --- a/client/tests/unit/sync/tilePrefetcher.test.ts +++ b/client/tests/unit/sync/tilePrefetcher.test.ts @@ -14,14 +14,18 @@ import { countTiles, prefetchTiles, prefetchTilesForTrip, + clearTileCache, MAX_TILES, + TILE_CONCURRENCY, type TileBbox, } from '../../../src/sync/tilePrefetcher'; import { offlineDb, clearAll, upsertSyncMeta } from '../../../src/db/offlineDb'; +import { setAuthed } from '../../../src/sync/authGate'; import { buildPlace } from '../../helpers/factories'; beforeEach(async () => { await clearAll(); + setAuthed(true); Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }); // Stub fetch + serviceWorker so prefetch path is exercised vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); @@ -33,6 +37,7 @@ beforeEach(async () => { }); afterEach(() => { + setAuthed(false); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -121,6 +126,23 @@ describe('buildTileUrl', () => { expect(url).toMatch(/^https:\/\/[abcd]\.tiles\.example\.com\/10\/0\/0\.png$/); }); + it('picks the subdomain deterministically so a tile keeps one URL', () => { + const tmpl = 'https://{s}.tiles.example.com/{z}/{x}/{y}.png'; + // A rotating counter would hand out a different host on each call, which + // both breaks the cache lookup and stores the tile four times over. + const first = buildTileUrl(tmpl, 10, 479, 329); + buildTileUrl(tmpl, 10, 480, 330); + expect(buildTileUrl(tmpl, 10, 479, 329)).toBe(first); + }); + + it('spreads neighbouring tiles across subdomains', () => { + const tmpl = 'https://{s}.tiles.example.com/{z}/{x}/{y}.png'; + const hosts = new Set( + [0, 1, 2, 3].map(dy => buildTileUrl(tmpl, 10, 479, 329 + dy).slice(8, 9)), + ); + expect(hosts.size).toBe(4); + }); + it('removes {r} (retina placeholder)', () => { const tmpl = 'https://tiles.example.com/{z}/{x}/{y}{r}.png'; const url = buildTileUrl(tmpl, 10, 0, 0); @@ -185,6 +207,102 @@ describe('prefetchTiles — normal operation', () => { }); }); +// ── throttling ──────────────────────────────────────────────────────────────── + +describe('prefetchTiles — throttling', () => { + /** fetch stub that resolves on the next macrotask and tracks concurrency. */ + function trackingFetch() { + const state = { inFlight: 0, peak: 0, calls: 0 }; + vi.stubGlobal('fetch', vi.fn(() => { + state.calls++; + state.inFlight++; + state.peak = Math.max(state.peak, state.inFlight); + return new Promise(resolve => setTimeout(() => { + state.inFlight--; + resolve({ ok: true }); + }, 0)); + })); + return state; + } + + it('never exceeds TILE_CONCURRENCY requests in flight', async () => { + const state = trackingFetch(); + const bbox: TileBbox = { minLat: 48.0, maxLat: 49.0, minLng: 2.0, maxLng: 3.0 }; + + await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 12); + + expect(state.calls).toBeGreaterThan(TILE_CONCURRENCY); + expect(state.peak).toBeLessThanOrEqual(TILE_CONCURRENCY); + }); + + it('resolves only once every tile has been dealt with', async () => { + const state = trackingFetch(); + const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 }; + + const fetched = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 11); + + expect(fetched).toBe(state.calls); + expect(state.inFlight).toBe(0); + }); + + it('stops fetching when the user logs out mid-run', async () => { + let calls = 0; + vi.stubGlobal('fetch', vi.fn(() => { + if (++calls === TILE_CONCURRENCY) setAuthed(false); + return Promise.resolve({ ok: true }); + })); + const bbox: TileBbox = { minLat: 48.0, maxLat: 49.0, minLng: 2.0, maxLng: 3.0 }; + + await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 12); + + // The workers finish their current tile, then bail — nowhere near the ~336 + // tiles this bbox enumerates. + expect(calls).toBeLessThan(2 * TILE_CONCURRENCY); + }); + + it('goes quiet when the connection drops mid-run', async () => { + let calls = 0; + vi.stubGlobal('fetch', vi.fn(() => { + if (++calls === TILE_CONCURRENCY) { + Object.defineProperty(navigator, 'onLine', { value: false, configurable: true }); + } + return Promise.resolve({ ok: true }); + })); + const bbox: TileBbox = { minLat: 48.0, maxLat: 49.0, minLng: 2.0, maxLng: 3.0 }; + + await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 12); + + expect(calls).toBeLessThan(2 * TILE_CONCURRENCY); + }); +}); + +// ── cache reuse ─────────────────────────────────────────────────────────────── + +describe('prefetchTiles — cache reuse', () => { + it('skips tiles already in Cache Storage instead of hitting the network', async () => { + vi.stubGlobal('caches', { + open: vi.fn().mockResolvedValue({ match: vi.fn().mockResolvedValue({}) }), + }); + const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 }; + + const fetched = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 11); + + expect(fetched).toBe(0); + expect(vi.mocked(fetch)).not.toHaveBeenCalled(); + }); + + it('still fetches when Cache Storage has no entry for the tile', async () => { + vi.stubGlobal('caches', { + open: vi.fn().mockResolvedValue({ match: vi.fn().mockResolvedValue(undefined) }), + }); + const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 }; + + const fetched = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 11); + + expect(fetched).toBeGreaterThan(0); + }); +}); + // ── prefetchTilesForTrip ────────────────────────────────────────────────────── describe('prefetchTilesForTrip', () => { @@ -239,6 +357,62 @@ describe('prefetchTilesForTrip', () => { }); }); +// ── repeat runs ─────────────────────────────────────────────────────────────── + +describe('prefetchTilesForTrip — repeat runs', () => { + const places = [buildPlace({ trip_id: 1, lat: 48.8566, lng: 2.3522 })]; + const tmpl = 'https://{s}.example.com/{z}/{x}/{y}.png'; + + beforeEach(async () => { + await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 }); + }); + + it('does nothing on a second run with an unchanged bbox', async () => { + await prefetchTilesForTrip(1, places, tmpl); + const first = vi.mocked(fetch).mock.calls.length; + expect(first).toBeGreaterThan(0); + + vi.mocked(fetch).mockClear(); + await prefetchTilesForTrip(1, places, tmpl); + + expect(vi.mocked(fetch)).not.toHaveBeenCalled(); + }); + + it('prefetches again once a place moves the bbox', async () => { + await prefetchTilesForTrip(1, places, tmpl); + vi.mocked(fetch).mockClear(); + + const moved = [...places, buildPlace({ trip_id: 1, lat: 45.4642, lng: 9.19 })]; + await prefetchTilesForTrip(1, moved, tmpl); + + expect(vi.mocked(fetch)).toHaveBeenCalled(); + }); + + it('runs anyway when forced (prepare for offline)', async () => { + await prefetchTilesForTrip(1, places, tmpl); + vi.mocked(fetch).mockClear(); + + await prefetchTilesForTrip(1, places, tmpl, true); + + expect(vi.mocked(fetch)).toHaveBeenCalled(); + }); + + it('clearTileCache resets the bboxes so the next sync refills the cache', async () => { + await prefetchTilesForTrip(1, places, tmpl); + vi.mocked(fetch).mockClear(); + + vi.stubGlobal('caches', { delete: vi.fn().mockResolvedValue(true) }); + await clearTileCache(); + expect((await offlineDb.syncMeta.get(1))!.tilesBbox).toBeNull(); + + vi.unstubAllGlobals(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); + await prefetchTilesForTrip(1, places, tmpl); + + expect(vi.mocked(fetch)).toHaveBeenCalled(); + }); +}); + // ── cap coherence ─────────────────────────────────────────────────────────────── describe('MAX_TILES budget', () => {