mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 05:11:46 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user