Skip to content

Commit 4b7167d

Browse files
authored
fix(tiles): serve stale cached tiles while refreshing in background
1 parent 1f1447b commit 4b7167d

6 files changed

Lines changed: 933 additions & 91 deletions

File tree

ClientApps/trip-editor/src/map/leafletAdapter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { placeMarkerIcon, previewMarkerIcon, regionMarkerIcon } from './markerRe
99
import { placePopupHtml } from './placePopupRendering';
1010
import { createSearchPreviewLayer } from './searchPreviewLayer';
1111
import { createSegmentRouteWorkLayer } from './segmentRouteWorkLayer';
12+
import { createTripEditorTileLayer } from './tileRetryLayer';
1213
export type { AreaPolygonWorkOptions } from './areaPolygonWorkLayer';
1314
export type { SegmentRouteWorkOptions } from './segmentRouteWorkLayer';
1415

@@ -67,7 +68,7 @@ export const createTripEditorMap = (element: HTMLElement, tilesUrl: string, opti
6768

6869
map.on('moveend zoomend', updateMapViewDataset);
6970

70-
L.tileLayer(tilesUrl, {
71+
createTripEditorTileLayer(tilesUrl, {
7172
attribution: providerAttribution(window.wayfarerTileConfig?.attribution),
7273
maxZoom: 19
7374
}).addTo(map);
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import L from 'leaflet';
2+
3+
type RetryTileImage = HTMLImageElement & {
4+
_abortController?: AbortController | null;
5+
};
6+
7+
const tileConfig = (): { burstCapacity?: number; retryAfterSeconds?: number } => window.wayfarerTileConfig ?? {};
8+
const tilePoolSize = (): number => Math.ceil((tileConfig().burstCapacity ?? 12) * 0.75);
9+
const jitter = (delayMs: number): number => delayMs * (0.75 + Math.random() * 0.5);
10+
let tileFetchesInFlight = 0;
11+
const waitingTileFetches: Array<{ resolve: (acquired: boolean) => void }> = [];
12+
13+
const acquireTileFetchSlot = (signal: AbortSignal): Promise<boolean> => {
14+
if (signal.aborted) {
15+
return Promise.resolve(false);
16+
}
17+
18+
if (tileFetchesInFlight < tilePoolSize()) {
19+
tileFetchesInFlight += 1;
20+
return Promise.resolve(true);
21+
}
22+
23+
return new Promise(resolve => {
24+
const entry = { resolve };
25+
waitingTileFetches.push(entry);
26+
signal.addEventListener('abort', () => {
27+
const index = waitingTileFetches.indexOf(entry);
28+
if (index !== -1) {
29+
waitingTileFetches.splice(index, 1);
30+
resolve(false);
31+
}
32+
}, { once: true });
33+
});
34+
};
35+
36+
const releaseTileFetchSlot = (): void => {
37+
const next = waitingTileFetches.shift();
38+
if (next) {
39+
next.resolve(true);
40+
return;
41+
}
42+
43+
tileFetchesInFlight = Math.max(0, tileFetchesInFlight - 1);
44+
};
45+
46+
/**
47+
* Leaflet tile layer with the same retry and concurrency behavior as the legacy shared layer.
48+
*/
49+
class TripEditorRetryTileLayer extends L.TileLayer {
50+
private readonly maxRetries = 5;
51+
private readonly retryDelayMs = 1000;
52+
53+
createTile(coords: L.Coords, done: L.DoneCallback): HTMLElement {
54+
const tile: RetryTileImage = document.createElement('img');
55+
tile.alt = '';
56+
tile.setAttribute('role', 'presentation');
57+
58+
const controller = new AbortController();
59+
tile._abortController = controller;
60+
this.fetchWithRetry(this.getTileUrl(coords), tile, done, 0, controller.signal);
61+
return tile;
62+
}
63+
64+
_removeTile(key: string): void {
65+
const tile = (this as unknown as { _tiles?: Record<string, { el?: RetryTileImage }> })._tiles?.[key];
66+
if (tile?.el?._abortController) {
67+
tile.el._abortController.abort();
68+
tile.el._abortController = null;
69+
}
70+
71+
if (tile?.el?.src.startsWith('blob:')) {
72+
URL.revokeObjectURL(tile.el.src);
73+
}
74+
75+
super._removeTile(key);
76+
}
77+
78+
private fetchWithRetry(url: string, tile: RetryTileImage, done: L.DoneCallback, attempt: number, signal: AbortSignal): void {
79+
acquireTileFetchSlot(signal).then(acquired => {
80+
if (!acquired) return;
81+
let slotReleased = false;
82+
const releaseSlotOnce = (): void => {
83+
if (!slotReleased) {
84+
slotReleased = true;
85+
releaseTileFetchSlot();
86+
}
87+
};
88+
89+
if (signal.aborted) {
90+
releaseSlotOnce();
91+
return;
92+
}
93+
94+
fetch(url, { signal })
95+
.then(response => {
96+
releaseSlotOnce();
97+
if (response.ok) return this.loadTileBlob(response, tile, done, signal);
98+
if (response.status === 503) {
99+
this.scheduleRetry(url, tile, done, attempt, signal, response.headers.get('Retry-After'));
100+
return;
101+
}
102+
103+
done(new Error(`Tile fetch failed: ${response.status}`), tile);
104+
})
105+
.catch(error => {
106+
releaseSlotOnce();
107+
if (error instanceof DOMException && error.name === 'AbortError') return;
108+
this.scheduleRetry(url, tile, done, attempt, signal);
109+
});
110+
});
111+
}
112+
113+
private scheduleRetry(url: string, tile: RetryTileImage, done: L.DoneCallback, attempt: number, signal: AbortSignal, retryAfterHeader?: string | null): void {
114+
if (attempt < this.maxRetries) {
115+
const retryAfterSeconds = retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : Number.NaN;
116+
const serverDelay = Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
117+
? retryAfterSeconds * 1000
118+
: this.retryDelayMs * Math.pow(2, attempt);
119+
const delayMs = jitter(Math.min(Math.max(serverDelay, this.retryDelayMs), 10_000));
120+
window.setTimeout(() => !signal.aborted && this.fetchWithRetry(url, tile, done, attempt + 1, signal), delayMs);
121+
return;
122+
}
123+
124+
this.scheduleSlowRetry(url, tile, done, signal);
125+
}
126+
127+
private scheduleSlowRetry(url: string, tile: RetryTileImage, done: L.DoneCallback, signal: AbortSignal): void {
128+
const retryAfterSeconds = tileConfig().retryAfterSeconds ?? 6;
129+
window.setTimeout(() => {
130+
if (!signal.aborted) this.fetchWithRetry(url, tile, done, this.maxRetries, signal);
131+
}, jitter(retryAfterSeconds * 3 * 1000));
132+
}
133+
134+
private async loadTileBlob(response: Response, tile: RetryTileImage, done: L.DoneCallback, signal: AbortSignal): Promise<void> {
135+
const blob = await response.blob();
136+
if (signal.aborted) return;
137+
138+
tile.onload = (): void => {
139+
URL.revokeObjectURL(tile.src);
140+
done(null, tile);
141+
};
142+
tile.onerror = (): void => {
143+
URL.revokeObjectURL(tile.src);
144+
done(new Error('Tile image decode failed'), tile);
145+
};
146+
tile.src = URL.createObjectURL(blob);
147+
}
148+
}
149+
150+
/**
151+
* Creates the Trip Editor tile layer with fetch-based retry and concurrency controls.
152+
*/
153+
export const createTripEditorTileLayer = (tilesUrl: string, options: L.TileLayerOptions): L.TileLayer =>
154+
new TripEditorRetryTileLayer(tilesUrl, options);

0 commit comments

Comments
 (0)