mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(offline): throttle the map tile prefetch
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.
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user