fix(pwa): persist offline storage + Mapbox offline policy (H8, H9) (#1184)

H8: prefetched tiles and file blobs could be evicted under storage pressure
(worsened by opaque tile responses inflating the quota ~7MB each), blanking the
offline map right when a traveler needs it. Request persistent storage at app
init so the browser exempts our caches from eviction. We deliberately keep tile
requests no-cors (a cors switch would break self-hosted/custom tile providers
without CORS headers), so persistence is the safe mitigation rather than
de-opaquing responses.

H9: Mapbox GL users had no offline map at all — no runtimeCaching matched the
Mapbox hosts. Add a StaleWhileRevalidate rule for api.mapbox.com /
*.tiles.mapbox.com so visited areas are available offline (best-effort; full
pre-download still requires the Leaflet renderer, now documented).

- new sync/persistentStorage.ts requestPersistentStorage(), called from main.tsx
- vite.config: mapbox-tiles SW cache rule
- MapViewAuto / tilePrefetcher comments document the offline-maps policy
- tests for the persist helper (granted / already-persisted / absent / rejects)
This commit is contained in:
jubnl
2026-06-15 09:33:35 +02:00
committed by GitHub
parent 4d072b4cb8
commit 1ed00b67ad
6 changed files with 94 additions and 2 deletions
@@ -5,6 +5,11 @@ import { MapViewGL } from './MapViewGL'
// Auto-selects the map renderer based on user settings. Keeps the existing // Auto-selects the map renderer based on user settings. Keeps the existing
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively // Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
// behind a toggle. Atlas is not affected — it imports Leaflet directly. // 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function MapViewAuto(props: any) { export function MapViewAuto(props: any) {
const provider = useSettingsStore(s => s.settings.map_provider) const provider = useSettingsStore(s => s.settings.map_provider)
+3
View File
@@ -15,8 +15,11 @@ import '@fontsource/geist-sans/500.css'
import '@fontsource/geist-sans/600.css' import '@fontsource/geist-sans/600.css'
import './index.css' import './index.css'
import { startConnectivityProbe } from './sync/connectivity' import { startConnectivityProbe } from './sync/connectivity'
import { requestPersistentStorage } from './sync/persistentStorage'
startConnectivityProbe() startConnectivityProbe()
// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction.
requestPersistentStorage()
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
+18
View File
@@ -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<boolean> {
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
}
}
+5 -2
View File
@@ -189,8 +189,11 @@ export async function prefetchTilesForTrip(
// their lower zooms cached instead of being skipped entirely. // their lower zooms cached instead of being skipped entirely.
// //
// NOTE: opaque (no-cors) tile responses are padded by Chromium to ~7 MB each // 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 // for quota accounting, so the real on-disk budget is far below 180 MB. We
// (audit H8) and navigator.storage.persist() (M6) are tracked separately. // 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) const fetched = await prefetchTiles(bbox, template)
// Update syncMeta with bbox and tile count // Update syncMeta with bbox and tile count
@@ -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);
});
});
+16
View File
@@ -47,6 +47,22 @@ export default defineConfig({
cacheableResponse: { statuses: [0, 200] }, 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 // API calls — network only. We deliberately do NOT cache API
// responses in the Service Worker: Workbox keys entries by URL and // responses in the Service Worker: Workbox keys entries by URL and