perf(offline): stop re-downloading artwork and the offline snapshot

- Sprites are cached as they are viewed instead of bulk-downloaded after
  every sign-in, in one shared cache that is never pruned and survives
  sign-out and account changes, since sprites never change.
- A "Save all artwork for offline" link saves every remaining sprite on
  request, shows the remaining size, and is hidden once all are saved.
- Page loads reuse an offline snapshot under 15 minutes old; edits,
  retries and a new sign-in still sync immediately.
- An expired session no longer wipes offline data; only the Sign Out
  button does.
This commit is contained in:
Josh Creek
2026-09-14 16:19:22 +01:00
parent 721c44dcd0
commit 030571fd14
3 changed files with 258 additions and 91 deletions
+98 -45
View File
@@ -1,6 +1,16 @@
const OFFLINE_CACHE_PREFIX = 'livingdex-offline-';
const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`;
const OFFLINE_META_URL = '/__offline/current';
// Must match OFFLINE_META_FORMAT in src/lib/stores/offlineSync.ts. Bumped when artwork moved to the
// shared sprite cache, so pages re-sync older copies instead of reusing them.
const OFFLINE_META_FORMAT = 2;
// Sprites never change at a given URL and aren't user data, so one cache serves every account and is
// kept forever: it deliberately sits outside OFFLINE_CACHE_PREFIX, which sign-out, account changes
// and sync cleanup all delete. Only bump the version if the files at existing URLs are replaced.
const SPRITE_CACHE = 'livingdex-sprites-v1';
// Every sprite file (all forms, shiny and female) with its size; generated by
// scripts/sprite-manifest.mjs. Kept in SPRITE_CACHE, so it changes exactly when the sprites do.
const SPRITE_MANIFEST_URL = '/sprites-small/manifest.json';
const ARTWORK_FETCH_TIMEOUT_MS = 15_000;
let offlineEpoch = 0;
let offlineOperation = Promise.resolve();
@@ -16,10 +26,50 @@ function dataCacheName(userId, generation) {
return `${OFFLINE_CACHE_PREFIX}data-v1-${userId}-${generation}`;
}
// Sprites never change at a given URL, so each user keeps one artwork cache that is topped up
// incrementally instead of being rebuilt on every sync.
function artworkCacheName(userId) {
return `${OFFLINE_CACHE_PREFIX}art-v2-${userId}`;
// The old per-user artwork caches hold full-size (or opaque, quota-padded) sprites.
function isObsoleteArtworkCache(name) {
return name.startsWith(`${OFFLINE_CACHE_PREFIX}art-`);
}
async function deleteObsoleteArtworkCaches() {
await Promise.all(
(await caches.keys()).filter(isObsoleteArtworkCache).map((name) => caches.delete(name))
);
}
async function loadSpriteManifest(cache) {
let response = await cache.match(SPRITE_MANIFEST_URL);
if (!response) {
response = await fetch(SPRITE_MANIFEST_URL);
if (!response.ok) throw new Error(`Sprite list unavailable (HTTP ${response.status})`);
await cache.put(SPRITE_MANIFEST_URL, response.clone());
}
const manifest = await response.json();
if (manifest?.version !== 1 || !Array.isArray(manifest.files))
throw new Error('Unsupported sprite list');
return manifest.files;
}
// Maps every sprite in the manifest to its absolute URL under `root` (the page's sprite folder).
async function allSpriteSizes(cache, root) {
const sizes = new Map();
for (const [file, size] of await loadSpriteManifest(cache)) {
const url = new URL(`${root}/${file}`, self.location.origin);
if (!isSpriteUrl(url)) throw new Error('Invalid sprite location');
sizes.set(url.href, size);
}
return sizes;
}
async function missingSprites(cache, root) {
const sizes = await allSpriteSizes(cache, root);
const cached = new Set((await cache.keys()).map((request) => request.url));
const missing = [...sizes.keys()].filter((url) => !cached.has(url));
return {
total: sizes.size,
missing,
missingBytes: missing.reduce((bytes, url) => bytes + sizes.get(url), 0)
};
}
// Covers both the local `/sprites-small/...` folder and the GitHub-hosted copy of it.
@@ -46,23 +96,13 @@ async function notifyOfflineDataCleared() {
for (const client of windows) client.postMessage({ type: 'OFFLINE_DATA_CLEARED' });
}
async function cacheArtwork(cache, urls, isCurrent) {
const wanted = new Set(urls.map((url) => new URL(url, self.location.origin).href));
const cachedRequests = await cache.keys();
const cached = new Set(cachedRequests.map((request) => request.url));
await Promise.all(
cachedRequests
.filter((request) => !wanted.has(request.url))
.map((request) => cache.delete(request))
);
const missing = [...wanted].filter((url) => !cached.has(url));
async function fetchSprites(cache, missing) {
let next = 0;
let failed = 0;
const workers = Array.from({ length: Math.min(6, missing.length) }, async () => {
for (;;) {
const index = next++;
if (index >= missing.length || !isCurrent()) return;
if (index >= missing.length) return;
const url = missing[index];
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ARTWORK_FETCH_TIMEOUT_MS);
@@ -120,6 +160,32 @@ self.addEventListener('message', (event) => {
);
return;
}
// Sprites aren't account data, so these work for whoever is signed in and survive sign-out.
if (event.data?.type === 'ARTWORK_STATUS' || event.data?.type === 'CACHE_ALL_ARTWORK') {
const download = event.data.type === 'CACHE_ALL_ARTWORK';
const root = String(event.data.spriteRoot ?? '');
event.waitUntil(
(async () => {
const cache = await caches.open(SPRITE_CACHE);
let status = await missingSprites(cache, root);
let failedArtwork = 0;
if (download && status.missing.length > 0) {
failedArtwork = await fetchSprites(cache, status.missing);
status = await missingSprites(cache, root);
}
reply({
ok: true,
total: status.total,
missing: status.missing.length,
missingBytes: status.missingBytes,
failedArtwork
});
})().catch((error) =>
reply({ ok: false, error: error instanceof Error ? error.message : String(error) })
)
);
return;
}
if (event.data?.type !== 'SYNC_OFFLINE_SNAPSHOT') return;
const syncEpoch = offlineEpoch;
const syncUserId = claimedUserId;
@@ -140,13 +206,7 @@ self.addEventListener('message', (event) => {
const previousMeta = await currentOfflineMeta();
if (previousMeta?.userId && previousMeta.userId !== snapshot.userId)
await clearOfflineData();
// Per-sync artwork caches of opaque responses could fill the whole storage quota, so drop
// them before writing anything new.
await Promise.all(
(await caches.keys())
.filter((name) => name.startsWith(`${OFFLINE_CACHE_PREFIX}art-v1-`))
.map((name) => caches.delete(name))
);
await deleteObsoleteArtworkCaches();
const timestamp = String(snapshot.generatedAt).replace(/[^0-9]/g, '');
const generation = `${timestamp}-${crypto.randomUUID()}`;
@@ -161,18 +221,15 @@ self.addEventListener('message', (event) => {
if (!isCurrent())
throw new Error('Offline synchronization was superseded by an account change');
// Commit the collection before any artwork so a slow or failing sprite download can never
// prevent the offline copy from being saved.
const artworkCache = artworkCacheName(snapshot.userId);
const metaCache = await caches.open(OFFLINE_META_CACHE);
await metaCache.put(
OFFLINE_META_URL,
new Response(
JSON.stringify({
format: OFFLINE_META_FORMAT,
userId: snapshot.userId,
generatedAt: snapshot.generatedAt,
dataCache: nextData,
artworkCache
dataCache: nextData
}),
{
headers: { 'Content-Type': 'application/json' }
@@ -192,19 +249,14 @@ self.addEventListener('message', (event) => {
.filter(
(name) =>
name.startsWith(OFFLINE_CACHE_PREFIX) &&
![OFFLINE_META_CACHE, nextData, artworkCache].includes(name)
![OFFLINE_META_CACHE, nextData].includes(name)
)
.map((name) => caches.delete(name))
);
const failedArtwork = await cacheArtwork(
await caches.open(artworkCache),
Array.from(new Set(event.data.artworkUrls ?? [])),
isCurrent
);
if (!isCurrent())
throw new Error('Offline synchronization was superseded by an account change');
reply({ ok: true, failedArtwork });
// Artwork is not downloaded here: the fetch handler caches sprites as they are viewed, and
// CACHE_ALL_ARTWORK fetches the rest only when the user asks for it.
reply({ ok: true });
} catch (error) {
if (!committed && nextData) await caches.delete(nextData).catch(() => undefined);
reply({ ok: false, error: error instanceof Error ? error.message : String(error) });
@@ -213,6 +265,10 @@ self.addEventListener('message', (event) => {
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(deleteObsoleteArtworkCaches());
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (event.request.mode === 'navigate' && !['/offline', '/offline.html'].includes(url.pathname)) {
@@ -227,17 +283,14 @@ self.addEventListener('fetch', (event) => {
);
return;
}
if (event.request.destination !== 'image') return;
if (event.request.destination !== 'image' || !isSpriteUrl(url)) return;
event.respondWith(
(async () => {
const meta = await currentOfflineMeta();
if (!meta?.artworkCache) return fetch(event.request);
const cache = await caches.open(meta.artworkCache);
const cached = await cache.match(event.request, { ignoreSearch: true });
// Cache-first with fill-on-miss for everyone, signed in or not: a sprite is downloaded once
// and served from the cache from then on.
const cache = await caches.open(SPRITE_CACHE);
const cached = await cache.match(url.href, { ignoreSearch: true });
if (cached) return cached;
if (!isSpriteUrl(url)) return fetch(event.request);
// Cache-first with fill-on-miss: a sprite shown online is stored once and served from the
// cache from then on, so the bulk sync never has to download it again.
try {
const response = await fetch(url.href, {
mode: url.origin === self.location.origin ? 'same-origin' : 'cors'