From 5decdcfa1356b47d74dfc03fe38f3b5dae80e374 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 1 Sep 2026 17:45:08 +0200 Subject: [PATCH] fix(offline): cache osm-de and stadia tiles, prefetch down to the opening zoom (#2180) --- client/src/sync/glPrefetcher.ts | 5 ++- client/src/sync/tilePrefetcher.ts | 9 ++-- client/tests/unit/sync/glPrefetcher.test.ts | 6 ++- client/tests/unit/sync/tilePrefetcher.test.ts | 42 +++++++++++++++++++ client/vite.config.js | 22 ++++++++++ 5 files changed, 78 insertions(+), 6 deletions(-) diff --git a/client/src/sync/glPrefetcher.ts b/client/src/sync/glPrefetcher.ts index c8a7e1716..baa01307d 100644 --- a/client/src/sync/glPrefetcher.ts +++ b/client/src/sync/glPrefetcher.ts @@ -20,7 +20,10 @@ import type { Place } from '../types' /** OpenFreeMap serves vector tiles up to z14 and overzooms from there. */ const VECTOR_MAX_ZOOM = 14 -const VECTOR_MIN_ZOOM = 10 +// Zoom 0, not 10: MapLibre can only overzoom upward, so a wide trip opening at +// its fitBounds zoom (z6–9) rendered nothing offline with a z10 floor (#2180). +// The low zooms cost a few dozen tiles at most. +const VECTOR_MIN_ZOOM = 0 /** Requests in flight, kept low for the same reason the raster side keeps it low. */ const CONCURRENCY = 6 diff --git a/client/src/sync/tilePrefetcher.ts b/client/src/sync/tilePrefetcher.ts index 735f7b1e7..c9ac770f7 100644 --- a/client/src/sync/tilePrefetcher.ts +++ b/client/src/sync/tilePrefetcher.ts @@ -4,7 +4,10 @@ * * Algorithm: * 1. Compute bbox from trip's place coordinates + padding. - * 2. For zooms 10–16, enumerate tile XYZ coordinates within bbox. + * 2. For zooms 0–16, enumerate tile XYZ coordinates within bbox. The low + * zooms cost next to nothing (a handful of tiles) but are what the trip + * map actually opens at for a multi-city bbox — a z10 floor left the + * fitBounds view empty offline (#2180). * 3. Stop when cumulative tile estimate exceeds MAX_TILES (~50 MB). * 4. Fetch each tile URL so the Service Worker CacheFirst handler caches it, * at most TILE_CONCURRENCY at a time. @@ -228,7 +231,7 @@ async function openTileCache(): Promise { export async function prefetchTiles( bbox: TileBbox, tileUrlTemplate: string, - minZoom = 10, + minZoom = 0, maxZoom = 16, cartoKey?: string, ): Promise { @@ -362,7 +365,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, cartoKey) + const fetched = await prefetchTiles(bbox, template, 0, 16, cartoKey) // Update syncMeta with bbox and tile count const meta = await offlineDb.syncMeta.get(tripId) diff --git a/client/tests/unit/sync/glPrefetcher.test.ts b/client/tests/unit/sync/glPrefetcher.test.ts index 59eeca12d..8a411250c 100644 --- a/client/tests/unit/sync/glPrefetcher.test.ts +++ b/client/tests/unit/sync/glPrefetcher.test.ts @@ -139,7 +139,7 @@ describe('prefetchStyleAssets', () => { describe('prefetchVectorForPlaces', () => { const places = [buildPlace({ lat: 52.52, lng: 13.405 })]; - it('FE-SYNC-GLPF-007: downloads the tiles covering the trip, z10 to z14', async () => { + it('FE-SYNC-GLPF-007: downloads the tiles covering the trip, z0 to z14', async () => { const result = await prefetchVectorForPlaces(places, STYLE_URL); expect(result.template).toBe(TILE_TEMPLATE); expect(result.tiles).toBeGreaterThan(0); @@ -151,7 +151,9 @@ describe('prefetchVectorForPlaces', () => { ); // z14 is the last one OpenFreeMap serves; MapLibre scales it beyond that // rather than asking for another, which is why this stops where raster could not. - expect([...zooms].sort((a, b) => a - b)).toEqual([10, 11, 12, 13, 14]); + // The floor is z0: MapLibre cannot overzoom downward, so the fitBounds view + // of a wide trip needs the low zooms cached too (#2180). + expect([...zooms].sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]); }); it('FE-SYNC-GLPF-008: does nothing offline', async () => { diff --git a/client/tests/unit/sync/tilePrefetcher.test.ts b/client/tests/unit/sync/tilePrefetcher.test.ts index 24f734210..0fb9a886e 100644 --- a/client/tests/unit/sync/tilePrefetcher.test.ts +++ b/client/tests/unit/sync/tilePrefetcher.test.ts @@ -519,3 +519,45 @@ describe('the GL style document is not served stale (#1924)', () => { expect(config).toMatch(/cacheName:\s*'gl-map-styles'/); }); }); + +describe('shipped raster presets reach the map-tiles cache (#2180)', () => { + const config = readFileSync(resolve(process.cwd(), 'vite.config.js'), 'utf8'); + + // First-match semantics, like the #1924 harness above, but capturing the + // cacheName too: a preset whose tiles land in some other cache is as offline- + // dead as one that matches nothing. + const rules = [...config.matchAll(/urlPattern:\s*(\/[^\n]*?\/i),\s*\n\s*handler:\s*'([A-Za-z]+)',\s*\n\s*options:\s*\{\s*\n\s*cacheName:\s*'([^']+)'/g)].map(m => ({ + pattern: new RegExp(m[1].slice(1, m[1].lastIndexOf('/')), 'i'), + handler: m[2], + cacheName: m[3], + })); + const firstMatch = (url: string) => rules.find(r => r.pattern.test(url)); + + it('OpenStreetMap DE tiles are cached — the preset used to match no rule at all', () => { + const rule = firstMatch('https://tile.openstreetmap.de/13/4091/2722.png'); + expect(rule?.handler).toBe('CacheFirst'); + expect(rule?.cacheName).toBe('map-tiles'); + }); + + it('Stadia tiles are cached the same way', () => { + const rule = firstMatch('https://tiles.stadiamaps.com/tiles/alidade_smooth/13/4091/2722.png'); + expect(rule?.handler).toBe('CacheFirst'); + expect(rule?.cacheName).toBe('map-tiles'); + }); + + it('still refuses look-alike hosts', () => { + expect(firstMatch('https://tile.openstreetmap.de.example.com/13/4091/2722.png')).toBeUndefined(); + }); +}); + +describe('prefetch covers the opening zoom (#2180)', () => { + it('fetches the low zooms too, so a wide trip has tiles at its fitBounds view', async () => { + // A ~3° multi-city bbox opens around z7 — the old z10 floor left that view + // completely uncached offline. + const bbox: TileBbox = { minLat: 47.0, maxLat: 50.0, minLng: 6.0, maxLng: 9.0 }; + await prefetchTiles(bbox, 'https://tile.example.com/{z}/{x}/{y}.png'); + const urls = vi.mocked(fetch).mock.calls.map(c => String(c[0])); + expect(urls.some(u => u.includes('/0/'))).toBe(true); + expect(urls.some(u => u.includes('/7/'))).toBe(true); + }); +}); diff --git a/client/vite.config.js b/client/vite.config.js index 155c52ec6..a9db8a963 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -92,6 +92,28 @@ export default defineConfig(({ mode }) => ({ cacheableResponse: { statuses: [0, 200] }, }, }, + { + // OpenStreetMap DE — a shipped preset that matched no rule at all, so + // "Store map tiles offline" fetched thousands of tiles and stored none + // of them (#2180). Same cache, same limits as the rules above. + urlPattern: /^https:\/\/tile\.openstreetmap\.de\/.*/i, + handler: 'CacheFirst', + options: { + cacheName: 'map-tiles', + expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 }, + cacheableResponse: { statuses: [0, 200] }, + }, + }, + { + // Stadia Smooth — the other shipped raster preset with the same hole (#2180). + urlPattern: /^https:\/\/tiles\.stadiamaps\.com\/tiles\/.*/i, + handler: 'CacheFirst', + options: { + cacheName: 'map-tiles', + expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 }, + cacheableResponse: { statuses: [0, 200] }, + }, + }, { // The GL style DOCUMENT, for both providers. It has to be matched // before the tile rules below, because Workbox takes the first route