fix(offline): the osm-de and stadia prefetch was CSP-blocked and the opening view stayed grey (#2180)

connect-src never listed tile.openstreetmap.de or tiles.stadiamaps.com, so every prefetch fetch was refused before the service worker saw it, while the swallowed error still counted as a cached tile. Both hosts are allowed now and only reached tiles count. The prefetch rectangle is also widened to the screen the fitBounds view fills, on the raster and the vector path alike; the places extent alone left grey bands beside a north-south trip.
This commit is contained in:
Maurice
2026-09-02 19:00:35 +02:00
committed by Maurice
parent fc6843528c
commit 2ab2dcc3dc
6 changed files with 215 additions and 26 deletions
@@ -156,6 +156,22 @@ describe('prefetchVectorForPlaces', () => {
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-014: reaches as far as the screen the map opens on, not just the places extent', async () => {
// Berlin + Munich: fitBounds is height-limited, so the opening view shows far
// more longitude than the places extent, and that band was grey offline (#2180).
await prefetchVectorForPlaces([
buildPlace({ lat: 52.52, lng: 13.405 }),
buildPlace({ lat: 48.14, lng: 11.58 }),
], STYLE_URL);
const xs = [...cache.store.keys()]
.filter(u => u.endsWith('.pbf') && u.includes('/planet/'))
.map(u => u.split('/planet/')[1]!.split('/'))
.filter(parts => Number(parts[1]) === 7)
.map(parts => Number(parts[2]));
expect((Math.max(...xs) - Math.min(...xs) + 1) * 256).toBeGreaterThanOrEqual(window.innerWidth);
});
it('FE-SYNC-GLPF-008: does nothing offline', async () => {
Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true });
await expect(prefetchVectorForPlaces(places, STYLE_URL)).resolves.toEqual({ tiles: 0, template: null });
@@ -561,3 +561,88 @@ describe('prefetch covers the opening zoom (#2180)', () => {
expect(urls.some(u => u.includes('/7/'))).toBe(true);
});
});
describe('prefetch covers the opening viewport (#2180)', () => {
const tmpl = 'https://tile.example.com/{z}/{x}/{y}.png';
const tileCoords = () =>
vi.mocked(fetch).mock.calls
.map(call => /\/(\d+)\/(\d+)\/(\d+)\.png$/.exec(String(call[0])))
.filter((m): m is RegExpExecArray => m !== null)
.map(m => ({ z: Number(m[1]), x: Number(m[2]), y: Number(m[3]) }));
const spanPx = (values: number[]) => (Math.max(...values) - Math.min(...values) + 1) * 256;
it('reaches as far as the screen the map opens on, not just the places extent', async () => {
// Berlin + Munich: fitBounds is limited by the north-south span, so the map
// opens with several times the trip's longitude in view. The places extent
// is two tile columns wide at that zoom, and everything beside it was grey.
const bbox = computeBbox([
buildPlace({ lat: 52.52, lng: 13.405 }),
buildPlace({ lat: 48.14, lng: 11.58 }),
])!;
await prefetchTiles(bbox, tmpl, 7, 7);
const opening = tileCoords().filter(t => t.z === 7);
expect(spanPx(opening.map(t => t.x))).toBeGreaterThanOrEqual(window.innerWidth);
expect(spanPx(opening.map(t => t.y))).toBeGreaterThanOrEqual(window.innerHeight);
});
it('still covers the places extent corner to corner', async () => {
// The screen is a widening, not a replacement: past the opening zoom the
// bbox is the larger of the two and stays the thing being prefetched.
const bbox: TileBbox = { minLat: 48.0, maxLat: 49.0, minLng: 2.0, maxLng: 3.0 };
await prefetchTiles(bbox, tmpl, 9, 9);
const urls = vi.mocked(fetch).mock.calls.map(c => String(c[0]));
expect(urls).toContain(buildTileUrl(tmpl, 9, lngToTileX(2.0, 9), latToTileY(49.0, 9)));
expect(urls).toContain(buildTileUrl(tmpl, 9, lngToTileX(3.0, 9), latToTileY(48.0, 9)));
});
it('never asks for a tile outside the grid', async () => {
// Widening a world-spanning bbox at z0 would otherwise run x from -2 to 3,
// and every one of those URLs is a 404 stored as an opaque cache entry.
const bbox: TileBbox = { minLat: -60, maxLat: 60, minLng: -180, maxLng: 180 };
await prefetchTiles(bbox, tmpl, 0, 2);
// The whole grid at z0..z2 and not one tile more.
expect(tileCoords()).toHaveLength(1 + 4 + 16);
for (const { z, x, y } of tileCoords()) {
expect(x).toBeGreaterThanOrEqual(0);
expect(y).toBeGreaterThanOrEqual(0);
expect(x).toBeLessThan(Math.pow(2, z));
expect(y).toBeLessThan(Math.pow(2, z));
}
});
});
describe('prefetchTiles: a refused tile is not a cached tile (#2180)', () => {
const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 };
const tmpl = 'https://tile.example.com/{z}/{x}/{y}.png';
it('counts nothing when every request is refused', async () => {
// What a CSP block looks like from here. Counting these is how a prefetch
// that stored not one tile still reported "cached N tiles" to the console.
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
const fetched = await prefetchTiles(bbox, tmpl, 10, 11);
expect(vi.mocked(fetch)).toHaveBeenCalled();
expect(fetched).toBe(0);
});
it('counts the tiles that did go through', async () => {
let calls = 0;
vi.stubGlobal('fetch', vi.fn(() => (++calls % 2 === 1
? Promise.resolve({ ok: true })
: Promise.reject(new TypeError('Failed to fetch')))));
const fetched = await prefetchTiles(bbox, tmpl, 10, 11);
expect(fetched).toBeGreaterThan(0);
expect(fetched).toBeLessThan(calls);
});
});