Retire only the API cache when its version changes (PR #97 review F2, F3)
CI / ci (push) Successful in 2m36s
CI / ci (pull_request) Successful in 2m24s

F2: one shared cache version renamed every cache, so the v2 bump that
drops the v1 API cache (dinner responses with the pasted recipe_url) also
deleted saved photos, fonts and the precache. Each cache now carries its
own version in cacheVersions; only api is v2, so activate deletes
mealquest-api-v1 and keeps mealquest-precache-v1, -media-v1 and -fonts-v1.

F3: replace the source-text activation test with src/sw.test.ts, which
loads the worker with the Workbox precache and router stubbed, dispatches
activate against mocked Cache Storage, and asserts caches.delete is called
for mealquest-api-v1 only. tsconfig.sw.json type-checks the test with the
worker's types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 21:02:16 -04:00
co-authored by Claude Opus 5.5
parent 4e0c31b7d6
commit 9574d1920e
7 changed files with 78 additions and 27 deletions
+47
View File
@@ -0,0 +1,47 @@
// Runs the service worker's activate handler against mocked Cache Storage,
// with the Workbox precache and router stubbed out. tsconfig.sw.json checks
// this file with the worker's types, like src/sw.ts itself.
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('workbox-precaching', () => ({
PrecacheFallbackPlugin: class {},
matchPrecache: vi.fn(),
precacheAndRoute: vi.fn(),
}))
vi.mock('workbox-routing', () => ({ registerRoute: vi.fn() }))
describe('service worker activation', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('deletes the v1 API cache and keeps the precache, photo and font caches', async () => {
// A household on the v1 worker: its API cache held dinners with the
// pasted recipe_url; its photos and fonts are not in the precache.
const names = [
'mealquest-precache-v1',
'mealquest-api-v1',
'mealquest-media-v1',
'mealquest-fonts-v1',
'other-app-cache',
]
const cacheStorage = {
keys: vi.fn(async () => names),
delete: vi.fn(async () => true),
}
vi.stubGlobal('caches', cacheStorage)
vi.stubGlobal('__WB_MANIFEST', [])
await import('./sw')
const pending: Promise<unknown>[] = []
const activate = Object.assign(new Event('activate'), {
waitUntil: (promise: Promise<unknown>) => pending.push(promise),
})
self.dispatchEvent(activate)
await Promise.all(pending)
expect(pending).toHaveLength(1)
expect(cacheStorage.delete).toHaveBeenCalledWith('mealquest-api-v1')
expect(cacheStorage.delete).toHaveBeenCalledTimes(1)
})
})
+3 -3
View File
@@ -18,7 +18,7 @@ import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
import { createApiStrategy } from './sw/apiStrategy' import { createApiStrategy } from './sw/apiStrategy'
import { import {
cacheNames, cacheNames,
cacheVersion, cacheVersions,
routeFor, routeFor,
staleCaches, staleCaches,
type Route, type Route,
@@ -28,8 +28,8 @@ declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: Array<{ url: string; revision: string | null }> __WB_MANIFEST: Array<{ url: string; revision: string | null }>
} }
// The precache becomes mealquest-precache-v2. // The precache becomes mealquest-precache-v1.
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion }) setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersions.precache })
const day = 24 * 60 * 60 const day = 24 * 60 * 60
+10 -7
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { cacheFallbackHeader, cacheNames, markCacheFallback, routeFor, staleCaches, type Route } from './routes' import { cacheFallbackHeader, cacheNames, cacheVersions, markCacheFallback, routeFor, staleCaches, type Route } from './routes'
const origin = 'https://mealquest.test' const origin = 'https://mealquest.test'
@@ -147,19 +147,22 @@ describe('cache names', () => {
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/) for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
}) })
it('are v2, so activate deletes the v1 caches', () => { it('retire only the v1 API cache', () => {
// v1 API responses held dinners with the pasted recipe_url, which can // v1 API responses held dinners with the pasted recipe_url, which can
// carry a personal token; offline, the worker would still serve them. // carry a personal token; offline, the worker would still serve them.
expect(cacheNames.api).toBe('mealquest-api-v2') // The precache, photos and fonts keep their v1 caches.
const current = ['mealquest-precache-v2', ...Object.values(cacheNames)] expect(cacheNames).toEqual({
const v1 = ['mealquest-precache-v1', 'mealquest-api-v1', 'mealquest-media-v1', 'mealquest-fonts-v1'] api: 'mealquest-api-v2',
expect(staleCaches([...v1, ...current], current)).toEqual(v1) media: 'mealquest-media-v1',
fonts: 'mealquest-fonts-v1',
})
expect(cacheVersions.precache).toBe('v1')
}) })
}) })
describe('staleCaches', () => { describe('staleCaches', () => {
it('deletes only MealQuest caches that are not current', () => { it('deletes only MealQuest caches that are not current', () => {
const current = ['mealquest-precache-v2', ...Object.values(cacheNames)] const current = ['mealquest-precache-v1', ...Object.values(cacheNames)]
const names = [ const names = [
...current, ...current,
'mealquest-precache', 'mealquest-precache',
+14 -7
View File
@@ -24,16 +24,23 @@ export interface RouteRequest {
mode: RequestMode mode: RequestMode
} }
// Bumping the version renames every cache; activate then deletes the old ones. // Each cache carries its own version. Bumping one renames only that cache;
// v2 drops v1 API responses, whose dinners still carried the pasted // activate then deletes its old name and keeps every other cache, so saved
// recipe_url. // photos and fonts stay available offline.
export const cacheVersion = 'v2' export const cacheVersions = {
precache: 'v1',
// v2 retires the v1 API cache, whose dinner responses still carried the
// pasted recipe_url.
api: 'v2',
media: 'v1',
fonts: 'v1',
} as const
export const cachePrefix = 'mealquest-' export const cachePrefix = 'mealquest-'
export const cacheNames = { export const cacheNames = {
api: `${cachePrefix}api-${cacheVersion}`, api: `${cachePrefix}api-${cacheVersions.api}`,
media: `${cachePrefix}media-${cacheVersion}`, media: `${cachePrefix}media-${cacheVersions.media}`,
fonts: `${cachePrefix}fonts-${cacheVersion}`, fonts: `${cachePrefix}fonts-${cacheVersions.fonts}`,
} as const } as const
export const networkTimeoutSeconds = 3 export const networkTimeoutSeconds = 3
+2 -8
View File
@@ -1,8 +1,8 @@
// The service worker itself needs worker globals, so these tests read its // The service worker itself needs worker globals, so these tests read its
// source: they pin the parts routes.ts cannot express — the API cache's // source: they pin the parts routes.ts cannot express — the API cache's
// strategy, the font strategy's expiration, the font route's place ahead of // strategy, the font strategy's expiration, the font route's place ahead of
// the precache route, network-only requests left to the browser and stale // the precache route and network-only requests left to the browser.
// caches deleted on activate. // src/sw.test.ts runs the worker's activation against mocked Cache Storage.
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import source from '../sw.ts?raw' import source from '../sw.ts?raw'
@@ -38,12 +38,6 @@ describe('service worker source', () => {
expect(source).not.toContain('registerRoute(() => true') expect(source).not.toContain('registerRoute(() => true')
}) })
it('deletes every stale cache on activate', () => {
expect(source).toMatch(
/self\.addEventListener\('activate', \(event\) => \{\s*const current = \[workboxCacheNames\.precache, \.\.\.Object\.values\(cacheNames\)\][\s\S]*?staleCaches\(names, current\)\.map\(\(name\) => caches\.delete\(name\)\)/,
)
})
it('activates a waiting worker when the update prompt sends SKIP_WAITING', () => { it('activates a waiting worker when the update prompt sends SKIP_WAITING', () => {
expect(source).toMatch( expect(source).toMatch(
/self\.addEventListener\('message', \(event\) => \{\s*if \(event\.data\?\.type === 'SKIP_WAITING'\) void self\.skipWaiting\(\)/, /self\.addEventListener\('message', \(event\) => \{\s*if \(event\.data\?\.type === 'SKIP_WAITING'\) void self\.skipWaiting\(\)/,
+1 -1
View File
@@ -19,5 +19,5 @@
"noEmit": true "noEmit": true
}, },
"include": ["src", "vite.config.ts", "manifest.json"], "include": ["src", "vite.config.ts", "manifest.json"],
"exclude": ["src/sw.ts"] "exclude": ["src/sw.ts", "src/sw.test.ts"]
} }
+1 -1
View File
@@ -4,6 +4,6 @@
"lib": ["ES2022", "WebWorker"], "lib": ["ES2022", "WebWorker"],
"types": [] "types": []
}, },
"include": ["src/sw.ts"], "include": ["src/sw.ts", "src/sw.test.ts"],
"exclude": [] "exclude": []
} }