mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
bea3af4b45
After an update, a tab or a device whose service worker still serves the previous build asks for chunks the update removed. The server answered those requests with index.html and a 200, so the import failed with a MIME error, and everything caching in between was free to keep the HTML as the chunk. A precache install that met a missing file stored it, and Workbox reuses hashed entries by URL, so the device showed "A new version is available" on every load, whatever the reload or the cleared browser cache, and carried the broken entry into every later release that keeps the file's name. The SPA fallback now answers a GET for a build file that is not on disk with a plain 404 (no-store) instead of index.html. A precache install refuses it, so the worker whose cache is complete stays in charge until the new build is. index.html is sent relative to the public dir, which also stops an install living under a dot directory from answering every deep link with a 404. On the client, the reload after a dead chunk (the automatic one and the button on the update screen) no longer goes straight back to the worker that served the broken shell. When that same worker is still in charge and the server answers, its precache and registration are dropped first, so the reload reaches the build the server runs now. Offline, and in offline mode, the shell is kept. Every recovery reload, and the reload of the version handover, asks for the page under a one-off query parameter, so a proxy or CDN that keeps index.html against its headers cannot hand back the stale shell either. A chunk the entry imports statically fails before any of that code runs. shell-guard.js, a small classic script loaded ahead of the entry, watches for exactly that failure and runs the same recovery, once per session. A stylesheet, font or icon that holds a page raises no error at all, so once the app has started and the browser is idle, it looks through the precache for entries that hold a page under a build file's name, fetches each from the server past every cache and puts it back in place. A repaired stylesheet is loaded again, and so are the app's stylesheets when a font was repaired, since the browser does not try a face that failed again in the same document. Only the headers are read, a precache found sound is not looked at again until its entries change, nothing is fetched while nothing is broken, and nothing happens offline, in offline mode or without storage. An answer that is not the file (an auth wall, its login page, a server error) leaves the entry for the next launch. A page the recovery loaded from the network already runs the version the server reports, so the version handover now records it instead of reloading it a second time once the new worker takes over. A page loaded past an older worker that is still in charge, as a hard reload does, still asks for the new worker and records the version only once that has taken over, so the next tab or launch cannot get the old shell without a handover.
487 lines
18 KiB
TypeScript
487 lines
18 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
/*
|
|
* reconcileAppVersion notes the worker that served the page when its module
|
|
* loads, so every test installs its fake service-worker container first and
|
|
* then loads a fresh copy of the module.
|
|
*/
|
|
|
|
class FakeWorker extends EventTarget {
|
|
state: string
|
|
constructor(state: string) {
|
|
super()
|
|
this.state = state
|
|
}
|
|
become(state: string) {
|
|
this.state = state
|
|
this.dispatchEvent(new Event('statechange'))
|
|
}
|
|
}
|
|
|
|
class FakeRegistration {
|
|
active: FakeWorker | null = null
|
|
installing: FakeWorker | null = null
|
|
waiting: FakeWorker | null = null
|
|
update = vi.fn(async () => this)
|
|
}
|
|
|
|
class FakeContainer extends EventTarget {
|
|
controller: FakeWorker | null
|
|
getRegistration = vi.fn()
|
|
constructor(controller: FakeWorker | null) {
|
|
super()
|
|
this.controller = controller
|
|
}
|
|
/** The browser activates a new worker and hands it this page. */
|
|
takeOver() {
|
|
this.controller = new FakeWorker('activated')
|
|
this.dispatchEvent(new Event('controllerchange'))
|
|
}
|
|
}
|
|
|
|
function installWorker(controller: FakeWorker | null = new FakeWorker('activated')) {
|
|
const registration = new FakeRegistration()
|
|
registration.active = controller
|
|
const container = new FakeContainer(controller)
|
|
container.getRegistration.mockResolvedValue(registration)
|
|
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
|
|
return { container, registration }
|
|
}
|
|
|
|
/** update() finds a new sw.js and leaves the new worker installing. */
|
|
function updateInstalls(registration: FakeRegistration) {
|
|
const incoming = new FakeWorker('installing')
|
|
registration.update.mockImplementation(async () => {
|
|
registration.installing = incoming
|
|
return registration
|
|
})
|
|
return incoming
|
|
}
|
|
|
|
async function loadModule() {
|
|
vi.resetModules()
|
|
return import('./versionHandover')
|
|
}
|
|
|
|
/** Lets one kind of storage throw while the other keeps working. */
|
|
function breakStorage(method: 'getItem' | 'setItem', broken: () => Storage) {
|
|
const original = Storage.prototype[method] as (...args: string[]) => unknown
|
|
vi.spyOn(Storage.prototype, method).mockImplementation(function (this: Storage, ...args: string[]) {
|
|
if (this === broken()) throw new DOMException('The operation is insecure.', 'SecurityError')
|
|
return original.apply(this, args)
|
|
} as never)
|
|
}
|
|
|
|
const marker = () => localStorage.getItem('trek_app_version')
|
|
// The version the bundle under test is built as (vite define, from package.json).
|
|
const BUILT = __TREK_UI_VERSION__
|
|
let reload: ReturnType<typeof vi.fn>
|
|
let plainReload: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
// Every reload here asks for the page under a fresh URL (reloadFresh), which is
|
|
// a location.replace. `plainReload` is the location.reload() it must not use.
|
|
reload = vi.fn()
|
|
plainReload = vi.fn()
|
|
Object.defineProperty(window, 'location', {
|
|
writable: true,
|
|
value: { ...window.location, href: 'http://localhost/dashboard', replace: reload, reload: plainReload },
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
delete (navigator as { serviceWorker?: unknown }).serviceWorker
|
|
})
|
|
|
|
describe('reconcileAppVersion: when nothing needs to happen', () => {
|
|
it.each([
|
|
['nothing at all', undefined],
|
|
['a number', 431],
|
|
['an empty string', ''],
|
|
['markup', '<script>alert(1)</script>'],
|
|
['an overlong value', '4'.repeat(65)],
|
|
])('FE-UTIL-VERSION-001: ignores %s as the reported version', async (_label, reported) => {
|
|
const { container } = installWorker()
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion(reported)
|
|
|
|
expect(marker()).toBe('4.3.0')
|
|
expect(container.getRegistration).not.toHaveBeenCalled()
|
|
expect(reload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-002: the first launch on a device only records the version', async () => {
|
|
const { container } = installWorker()
|
|
const { reconcileAppVersion } = await loadModule()
|
|
|
|
await reconcileAppVersion('4.3.1')
|
|
|
|
expect(marker()).toBe('4.3.1')
|
|
expect(container.getRegistration).not.toHaveBeenCalled()
|
|
expect(reload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-003: the version this device already runs changes nothing', async () => {
|
|
const { container } = installWorker()
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.1')
|
|
|
|
await reconcileAppVersion('4.3.1')
|
|
|
|
expect(container.getRegistration).not.toHaveBeenCalled()
|
|
expect(reload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-004: unreadable storage leaves the worker alone', async () => {
|
|
const { container } = installWorker()
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
breakStorage('getItem', () => localStorage)
|
|
|
|
await expect(reconcileAppVersion('4.3.1')).resolves.toBeUndefined()
|
|
|
|
expect(container.getRegistration).not.toHaveBeenCalled()
|
|
expect(reload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-005: a browser that throws on the service worker itself still loads the app', async () => {
|
|
Object.defineProperty(navigator, 'serviceWorker', {
|
|
configurable: true,
|
|
get() { throw new DOMException('The operation is insecure.', 'SecurityError') },
|
|
})
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion('4.3.1')
|
|
|
|
// Nothing could be asked, so the next launch asks again.
|
|
expect(marker()).toBe('4.3.0')
|
|
expect(reload).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('reconcileAppVersion: a new version without a handover to wait for', () => {
|
|
it('FE-UTIL-VERSION-010: without service worker support it records the version and reloads once', async () => {
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion('4.3.1')
|
|
|
|
expect(marker()).toBe('4.3.1')
|
|
expect(reload).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-011: without a registration it records the version and reloads once', async () => {
|
|
const { container } = installWorker(null)
|
|
container.getRegistration.mockResolvedValue(undefined)
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion('4.3.1')
|
|
|
|
expect(marker()).toBe('4.3.1')
|
|
expect(reload).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-012: a worker the browser swapped in since the page loaded gets one reload and no update', async () => {
|
|
const { container, registration } = installWorker()
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
// The browser's own check on navigation got there before the server answered.
|
|
container.takeOver()
|
|
|
|
await reconcileAppVersion('4.3.1')
|
|
|
|
expect(marker()).toBe('4.3.1')
|
|
expect(reload).toHaveBeenCalledTimes(1)
|
|
expect(registration.update).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-013: a worker claiming a page it did not serve is no takeover', async () => {
|
|
const { container, registration } = installWorker(null)
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
container.controller = new FakeWorker('activated')
|
|
|
|
await reconcileAppVersion('4.3.1')
|
|
|
|
expect(registration.update).toHaveBeenCalledTimes(1)
|
|
expect(reload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-014: an unchanged sw.js leaves nothing to hand over', async () => {
|
|
const { container, registration } = installWorker()
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion('4.3.1')
|
|
|
|
expect(registration.update).toHaveBeenCalledTimes(1)
|
|
expect(marker()).toBe('4.3.1')
|
|
expect(reload).not.toHaveBeenCalled()
|
|
|
|
// A takeover later in the session is not this launch's business.
|
|
container.takeOver()
|
|
expect(reload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-015: a page from the network built as the new version records it without a reload (#2524)', async () => {
|
|
const { container } = installWorker(null)
|
|
container.getRegistration.mockResolvedValue(undefined)
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion(BUILT)
|
|
|
|
expect(marker()).toBe(BUILT)
|
|
expect(reload).not.toHaveBeenCalled()
|
|
expect(plainReload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-016: ...and the worker registered after it claims the page without a reload (#2524)', async () => {
|
|
// Where the way out of a broken shell lands: the precache and the worker were
|
|
// dropped, the page came from the server, and the new worker is installing.
|
|
const { container, registration } = installWorker(null)
|
|
updateInstalls(registration)
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion(BUILT)
|
|
container.takeOver()
|
|
|
|
expect(marker()).toBe(BUILT)
|
|
expect(registration.update).not.toHaveBeenCalled()
|
|
expect(reload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-019: ...and so does a page the worker registered after it has claimed already (#2524)', async () => {
|
|
const { container, registration } = installWorker(null)
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
// The fresh precache was quick, and its worker took the page before the server answered.
|
|
container.takeOver()
|
|
registration.active = container.controller
|
|
|
|
await reconcileAppVersion(BUILT)
|
|
|
|
expect(marker()).toBe(BUILT)
|
|
expect(registration.update).not.toHaveBeenCalled()
|
|
expect(reload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-017: a page a worker served goes through the handover whatever it was built as', async () => {
|
|
const { container, registration } = installWorker()
|
|
updateInstalls(registration)
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion(BUILT)
|
|
expect(marker()).toBe('4.3.0')
|
|
|
|
container.takeOver()
|
|
expect(reload).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-018: the reload asks for the page under a fresh URL, past a proxy that keeps index.html (#2524)', async () => {
|
|
const { container } = installWorker(null)
|
|
container.getRegistration.mockResolvedValue(undefined)
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
// A kept index.html runs an older bundle than the server reports.
|
|
await reconcileAppVersion('4.3.1')
|
|
|
|
expect(plainReload).not.toHaveBeenCalled()
|
|
expect(reload).toHaveBeenCalledTimes(1)
|
|
const target = new URL(reload.mock.calls[0][0] as string)
|
|
expect(target.pathname).toBe('/dashboard')
|
|
expect(target.searchParams.get('trek-reload')).toBeTruthy()
|
|
})
|
|
|
|
})
|
|
|
|
describe('reconcileAppVersion: handing over to the new build', () => {
|
|
it('FE-UTIL-VERSION-020: reloads once the new worker has taken over, and only then records the version', async () => {
|
|
const { container, registration } = installWorker()
|
|
const incoming = updateInstalls(registration)
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion('4.3.1')
|
|
expect(marker()).toBe('4.3.0')
|
|
expect(reload).not.toHaveBeenCalled()
|
|
|
|
incoming.become('activated')
|
|
container.takeOver()
|
|
expect(marker()).toBe('4.3.1')
|
|
expect(reload).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-021: a worker already installed and waiting counts as the incoming one', async () => {
|
|
const { container, registration } = installWorker()
|
|
registration.waiting = new FakeWorker('installed')
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion('4.3.1')
|
|
expect(marker()).toBe('4.3.0')
|
|
|
|
container.takeOver()
|
|
expect(reload).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-022: an update that fails is left for the next launch, without a reload', async () => {
|
|
const { container, registration } = installWorker()
|
|
registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'))
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion('4.3.1')
|
|
|
|
expect(marker()).toBe('4.3.0')
|
|
expect(reload).not.toHaveBeenCalled()
|
|
container.takeOver()
|
|
expect(reload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-023: an install that goes redundant is left for the next launch', async () => {
|
|
const { container, registration } = installWorker()
|
|
const incoming = updateInstalls(registration)
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion('4.3.1')
|
|
incoming.become('redundant')
|
|
|
|
expect(marker()).toBe('4.3.0')
|
|
container.takeOver()
|
|
expect(reload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-024: a registration lookup that throws is left for the next launch', async () => {
|
|
const { container } = installWorker()
|
|
container.getRegistration.mockRejectedValue(new Error('no worker'))
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion('4.3.1')
|
|
|
|
expect(marker()).toBe('4.3.0')
|
|
expect(reload).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('reconcileAppVersion: a page loaded past an older worker', () => {
|
|
// A hard reload, or a load with the worker bypassed, gets the new bundle from
|
|
// the network while the previous build's worker stays registered and would
|
|
// serve its own shell to the next tab or launch (#2524).
|
|
function pastOlderWorker() {
|
|
const installed = installWorker(null)
|
|
installed.registration.active = new FakeWorker('activated')
|
|
return installed
|
|
}
|
|
|
|
it('FE-UTIL-VERSION-025: asks for the new worker and records the version only once it has taken over', async () => {
|
|
const { container, registration } = pastOlderWorker()
|
|
const incoming = updateInstalls(registration)
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion(BUILT)
|
|
|
|
expect(registration.update).toHaveBeenCalledTimes(1)
|
|
expect(marker()).toBe('4.3.0')
|
|
|
|
incoming.become('activated')
|
|
container.takeOver()
|
|
expect(marker()).toBe(BUILT)
|
|
// The page runs that bundle already, so there is nothing to reload for.
|
|
expect(reload).not.toHaveBeenCalled()
|
|
expect(plainReload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-026: an install that goes redundant leaves the marker for the next launch', async () => {
|
|
const { container, registration } = pastOlderWorker()
|
|
const incoming = updateInstalls(registration)
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion(BUILT)
|
|
incoming.become('redundant')
|
|
container.takeOver()
|
|
|
|
expect(marker()).toBe('4.3.0')
|
|
expect(reload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-027: an unchanged sw.js means that worker is this build, so the version is recorded', async () => {
|
|
const { container, registration } = pastOlderWorker()
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
// It claimed the page while the update was being checked.
|
|
registration.update.mockImplementation(async () => {
|
|
container.controller = registration.active
|
|
return registration
|
|
})
|
|
|
|
await reconcileAppVersion(BUILT)
|
|
|
|
expect(registration.update).toHaveBeenCalledTimes(1)
|
|
expect(marker()).toBe(BUILT)
|
|
expect(reload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-028: an update that fails leaves the marker for the next launch', async () => {
|
|
const { registration } = pastOlderWorker()
|
|
registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'))
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion(BUILT)
|
|
|
|
expect(marker()).toBe('4.3.0')
|
|
expect(reload).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('reconcileAppVersion: the reload guard', () => {
|
|
it('FE-UTIL-VERSION-030: a marker that cannot be written does not turn into a reload loop', async () => {
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
breakStorage('setItem', () => localStorage)
|
|
|
|
// Every load of this session sees the new version again.
|
|
await reconcileAppVersion('4.3.1')
|
|
await reconcileAppVersion('4.3.1')
|
|
await reconcileAppVersion('4.3.1')
|
|
|
|
expect(marker()).toBe('4.3.0')
|
|
expect(reload).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-031: the next release in the same session still gets its reload', async () => {
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
|
|
await reconcileAppVersion('4.3.1')
|
|
await reconcileAppVersion('4.3.1-1')
|
|
|
|
expect(marker()).toBe('4.3.1-1')
|
|
expect(reload).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('FE-UTIL-VERSION-032: without session storage there is no guard, so there is no reload either', async () => {
|
|
const { reconcileAppVersion } = await loadModule()
|
|
localStorage.setItem('trek_app_version', '4.3.0')
|
|
breakStorage('getItem', () => sessionStorage)
|
|
|
|
await reconcileAppVersion('4.3.1')
|
|
|
|
expect(marker()).toBe('4.3.1')
|
|
expect(reload).not.toHaveBeenCalled()
|
|
})
|
|
})
|