diff --git a/client/src/components/Map/MapViewAuto.tsx b/client/src/components/Map/MapViewAuto.tsx index 42677bf9..a1ce57a1 100644 --- a/client/src/components/Map/MapViewAuto.tsx +++ b/client/src/components/Map/MapViewAuto.tsx @@ -5,6 +5,11 @@ import { MapViewGL } from './MapViewGL' // Auto-selects the map renderer based on user settings. Keeps the existing // Leaflet MapView untouched so the Mapbox GL variant can mature iteratively // behind a toggle. Atlas is not affected — it imports Leaflet directly. +// +// Offline maps: only the Leaflet renderer supports full pre-download (raster +// tiles via sync/tilePrefetcher.ts). Mapbox GL is best-effort offline — its +// vector tiles are cached opportunistically by the Service Worker as you view +// them online (see the mapbox-tiles rule in vite.config.js), not prefetched. // eslint-disable-next-line @typescript-eslint/no-explicit-any export function MapViewAuto(props: any) { const provider = useSettingsStore(s => s.settings.map_provider) diff --git a/client/src/main.tsx b/client/src/main.tsx index e98ef77b..51bea6b0 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -15,8 +15,11 @@ import '@fontsource/geist-sans/500.css' import '@fontsource/geist-sans/600.css' import './index.css' import { startConnectivityProbe } from './sync/connectivity' +import { requestPersistentStorage } from './sync/persistentStorage' startConnectivityProbe() +// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction. +requestPersistentStorage() ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/client/src/sync/persistentStorage.ts b/client/src/sync/persistentStorage.ts new file mode 100644 index 00000000..45f62dc6 --- /dev/null +++ b/client/src/sync/persistentStorage.ts @@ -0,0 +1,18 @@ +/** + * Ask the browser for persistent storage so our offline data — prefetched map + * tiles, cached file blobs, the IndexedDB caches — is exempt from eviction under + * storage pressure. Without this the browser may purge tiles right when a + * traveler goes offline and needs them (audit H8 / M6). + * + * Best-effort and idempotent: returns whether persistence is (now) granted. + */ +export async function requestPersistentStorage(): Promise { + try { + if (typeof navigator === 'undefined' || !navigator.storage?.persist) return false + // Already persisted? Avoid re-prompting where the API distinguishes. + if (navigator.storage.persisted && (await navigator.storage.persisted())) return true + return await navigator.storage.persist() + } catch { + return false + } +} diff --git a/client/src/sync/tilePrefetcher.ts b/client/src/sync/tilePrefetcher.ts index e2fba143..016c9685 100644 --- a/client/src/sync/tilePrefetcher.ts +++ b/client/src/sync/tilePrefetcher.ts @@ -189,8 +189,11 @@ export async function prefetchTilesForTrip( // their lower zooms cached instead of being skipped entirely. // // NOTE: opaque (no-cors) tile responses are padded by Chromium to ~7 MB each - // for quota accounting, so the real on-disk budget is far below 180 MB. That - // (audit H8) and navigator.storage.persist() (M6) are tracked separately. + // for quota accounting, so the real on-disk budget is far below 180 MB. We + // keep no-cors deliberately: switching to cors would break self-hosted/custom + // 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) // Update syncMeta with bbox and tile count diff --git a/client/tests/unit/sync/persistentStorage.test.ts b/client/tests/unit/sync/persistentStorage.test.ts new file mode 100644 index 00000000..722053f7 --- /dev/null +++ b/client/tests/unit/sync/persistentStorage.test.ts @@ -0,0 +1,47 @@ +/** + * requestPersistentStorage (H8 / M6) — best-effort persistent storage request + * so prefetched tiles / file blobs / IndexedDB aren't evicted under pressure. + */ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { requestPersistentStorage } from '../../../src/sync/persistentStorage'; + +const original = (navigator as Navigator & { storage?: StorageManager }).storage; + +afterEach(() => { + Object.defineProperty(navigator, 'storage', { value: original, configurable: true }); + vi.restoreAllMocks(); +}); + +function stubStorage(storage: unknown) { + Object.defineProperty(navigator, 'storage', { value: storage, configurable: true }); +} + +describe('requestPersistentStorage', () => { + it('requests persistence when not already granted', async () => { + const persist = vi.fn().mockResolvedValue(true); + const persisted = vi.fn().mockResolvedValue(false); + stubStorage({ persist, persisted }); + + expect(await requestPersistentStorage()).toBe(true); + expect(persist).toHaveBeenCalledOnce(); + }); + + it('skips the prompt when already persisted', async () => { + const persist = vi.fn().mockResolvedValue(true); + const persisted = vi.fn().mockResolvedValue(true); + stubStorage({ persist, persisted }); + + expect(await requestPersistentStorage()).toBe(true); + expect(persist).not.toHaveBeenCalled(); + }); + + it('returns false (no throw) when the API is unavailable', async () => { + stubStorage(undefined); + expect(await requestPersistentStorage()).toBe(false); + }); + + it('returns false (no throw) when persist rejects', async () => { + stubStorage({ persist: vi.fn().mockRejectedValue(new Error('denied')) }); + expect(await requestPersistentStorage()).toBe(false); + }); +}); diff --git a/client/vite.config.js b/client/vite.config.js index e4be800f..b08517b0 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -47,6 +47,22 @@ export default defineConfig({ cacheableResponse: { statuses: [0, 200] }, }, }, + { + // Mapbox GL style, glyphs, sprites and vector tiles. Best-effort + // offline only: opportunistically caches what the user has already + // viewed online. Full pre-download offline maps require the Leaflet + // renderer (raster prefetch in tilePrefetcher.ts) — the GL vector + // pipeline is not prefetched. StaleWhileRevalidate keeps the basemap + // fresh online while still serving from cache when offline. Mapbox + // sends CORS, so responses are non-opaque (real 200s, no quota pad). + urlPattern: /^https:\/\/(api\.mapbox\.com|[a-d]\.tiles\.mapbox\.com)\/.*/i, + handler: 'StaleWhileRevalidate', + options: { + cacheName: 'mapbox-tiles', + expiration: { maxEntries: 3000, maxAgeSeconds: 30 * 24 * 60 * 60 }, + cacheableResponse: { statuses: [200] }, + }, + }, { // API calls — network only. We deliberately do NOT cache API // responses in the Service Worker: Workbox keys entries by URL and