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.
163 lines
6.9 KiB
TypeScript
163 lines
6.9 KiB
TypeScript
/**
|
|
* Bringing an installed app onto the build the server runs now.
|
|
*
|
|
* Every launch asks the server for its version and compares it with a marker
|
|
* this device keeps. A different version means a release was deployed while
|
|
* this page may still run the previous bundle, answered from the service
|
|
* worker's precache. The worker is asked to fetch the new build; Workbox
|
|
* ('autoUpdate' with skipWaiting and clientsClaim) installs the new precache
|
|
* and only then drops the old one, and the page reloads once the new worker
|
|
* has taken over.
|
|
*
|
|
* The marker only moves once that handover is done or turned out to be
|
|
* unnecessary. A handover that failed (sw.js unreachable, a precache install
|
|
* that broke off on a flaky connection) is simply tried again on the next
|
|
* launch. That retry cannot loop: a failure never reloads, since the same old
|
|
* worker would answer the reload, and every reload here is limited to one per
|
|
* version and session.
|
|
*/
|
|
|
|
import { reloadFresh } from './chunkReload'
|
|
import { currentController, servingWorker } from './serviceWorkerShell'
|
|
|
|
const VERSION_KEY = 'trek_app_version'
|
|
const RELOAD_KEY = 'trek_app_version_reload'
|
|
|
|
// The version this bundle was built as (vite.config.js). A bare tsc run has none,
|
|
// and then no version counts as this bundle's own.
|
|
const BUNDLE_VERSION: string = typeof __TREK_UI_VERSION__ === 'string' ? __TREK_UI_VERSION__ : ''
|
|
|
|
// The worker that served this page, noted when the app loads (serviceWorkerShell).
|
|
// When the browser's own check of sw.js installs the new build before the server
|
|
// has told us its version, the takeover is over before anyone listens for it.
|
|
const servedBy = servingWorker()
|
|
|
|
function markApplied(version: string): void {
|
|
try { localStorage.setItem(VERSION_KEY, version) } catch { /* site data blocked */ }
|
|
}
|
|
|
|
/**
|
|
* One reload per server version and session. When the marker cannot be written,
|
|
* every load sees the new version again, and a page without a worker would
|
|
* otherwise reload forever. Without session storage there is no such guard, so
|
|
* the page stays as it is rather than risking the loop.
|
|
*/
|
|
function reloadOnce(version: string): void {
|
|
try {
|
|
if (sessionStorage.getItem(RELOAD_KEY) === version) return
|
|
sessionStorage.setItem(RELOAD_KEY, version)
|
|
} catch {
|
|
return
|
|
}
|
|
// Under a URL no cache has seen yet: without a worker, a proxy that keeps
|
|
// index.html against its headers would answer a plain reload with the very
|
|
// shell this page runs, and the marker has moved on already (#2524).
|
|
reloadFresh()
|
|
}
|
|
|
|
/**
|
|
* Asks the worker for the new build and moves the marker once it has taken over.
|
|
* `runsIt` is a page that already runs the new bundle, so the takeover needs no
|
|
* reload to show it.
|
|
*/
|
|
async function handOver(reg: ServiceWorkerRegistration, version: string, runsIt: boolean): Promise<void> {
|
|
// Listening before asking for the update, so a quick takeover is not missed.
|
|
// Giving up disarms the listener: the reload belongs to this launch, and one
|
|
// arriving later in the session would hit the user in the middle of an edit.
|
|
let armed = true
|
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
if (!armed) return
|
|
markApplied(version)
|
|
if (!runsIt) reloadOnce(version)
|
|
}, { once: true })
|
|
|
|
try {
|
|
await reg.update()
|
|
} catch {
|
|
armed = false
|
|
return
|
|
}
|
|
|
|
const incoming = reg.installing ?? reg.waiting
|
|
if (!incoming) {
|
|
// sw.js is unchanged, so the release changed nothing the precache holds and
|
|
// the bundle this page runs is the current one.
|
|
armed = false
|
|
if (runsIt || currentController() === servedBy) markApplied(version)
|
|
return
|
|
}
|
|
// Precaching is all or nothing: one failed request out of several hundred and
|
|
// the new worker is thrown away while the old one stays in charge. The marker
|
|
// has not moved, so the next launch comes back here.
|
|
incoming.addEventListener('statechange', () => {
|
|
if (incoming.state === 'redundant') armed = false
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Compares the version the server reports with the one this device last ran
|
|
* and, when they differ, moves the app onto the new build. It never deletes a
|
|
* cache or unregisters a worker. That used to leave the device without an app
|
|
* shell until a fresh ~22 MB precache finished, so anyone who closed the app or
|
|
* lost signal in that window could no longer start it offline, and it threw
|
|
* away the map tiles and files the user had downloaded on purpose.
|
|
*/
|
|
export async function reconcileAppVersion(reported: unknown): Promise<void> {
|
|
// A version is a short release tag and nothing else. It arrives over the
|
|
// wire and is written to this device's storage, so only a value made of
|
|
// the characters a tag may contain is taken, as the match itself. Anything
|
|
// else is ignored, which also keeps a malformed value from being compared
|
|
// against the stored marker and starting an update on every launch.
|
|
const releaseTag = /^[\w.+-]{1,64}$/.exec(typeof reported === 'string' ? reported : '')
|
|
if (!releaseTag) return
|
|
const version = releaseTag[0]
|
|
|
|
let storedVersion: string | null
|
|
try {
|
|
storedVersion = localStorage.getItem(VERSION_KEY)
|
|
} catch {
|
|
return
|
|
}
|
|
if (!storedVersion) {
|
|
// The first launch on this device runs whatever the server just served.
|
|
markApplied(version)
|
|
return
|
|
}
|
|
if (storedVersion === version) return
|
|
|
|
let reg: ServiceWorkerRegistration | undefined
|
|
try {
|
|
reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined
|
|
} catch {
|
|
// Left for the next launch. A reload now would only reach the old worker again.
|
|
return
|
|
}
|
|
|
|
// A page that came from the network and was built as this version runs it
|
|
// already. That is where the way out of a broken app shell lands, before the
|
|
// new app could move the marker (#2524): the old worker is gone, and the one
|
|
// registered since is this build. Reloading once it has taken over would only
|
|
// interrupt the user a few seconds in, with nothing new to show. An older
|
|
// worker still in charge (this page was loaded past it, as a hard reload
|
|
// does) would serve its own build to the next tab or launch, so it is handed
|
|
// over like any other, just without the reload.
|
|
const runsIt = !servedBy && version === BUNDLE_VERSION
|
|
if (runsIt && (!reg?.active || reg.active === currentController())) {
|
|
markApplied(version)
|
|
return
|
|
}
|
|
|
|
// Without a worker the page came from the network, yet was not built as this
|
|
// version: a proxy or CDN handed out an index.html it kept, or the server
|
|
// reports a version its bundle was not built as, and then the reload only
|
|
// confirms what runs. A controller other than the one that served the page
|
|
// means the browser finished the handover by itself, and the reload is what
|
|
// shows the new build.
|
|
if (!reg || (servedBy && currentController() !== servedBy)) {
|
|
markApplied(version)
|
|
reloadOnce(version)
|
|
return
|
|
}
|
|
await handOver(reg, version, runsIt)
|
|
}
|