I have SPA without any navigation and service worker caching all static content (HTML, JS, CSS, images).
The service worker perfectly serves content while offline but updates are complicated.
- The service worker has stable name
service-worker.js
. - GIT commit hash is used as a cache name, thus deployment results in new content of the service worker.
- Service worker calls
skipWaiting()
duringinstall
andclients.claim()
duringactivate
to immediately take control. registration.update()
is called afternavigator.serviceWorker.register()
to make sure the latest service worker version is used on each page refresh.
Despite all this, after deploying the new SPA version with changed content and service worker and refreshing the page (Ctrl + R
) I still get stale content. But after the second page refresh (Ctrl + R
) I get the updated content.
To not depend on refreshing the page twice, I added window.location.refresh()
on controllerchange
event. This way second page refresh happens automatically and transparently to user.
I understand that skipWaiting()
and clients.claim()
are races, so you’ll only see the updated content if the new service worker fetches, installs and activates before the page tries to load the static content. Are there a recommended solution to this problem?
service-worker.ts
import {manifest} from '@parcel/service-worker';
async function install(): Promise<void> {
const cache = await caches.open(commitHash);
await cache.addAll(['/', ...new Set(manifest)]);
}
self.addEventListener('install', event => {
void self.skipWaiting();
event.waitUntil(install());
});
async function activate(): Promise<void> {
const keys = await caches.keys();
await Promise.all(keys.filter(key => key !== commitHash).map(key => caches.delete(key)));
await self.clients.claim();
}
self.addEventListener('activate', event => event.waitUntil(activate()));
async function cacheFallingBackToNetwork(request: Request): Promise<Response> {
const cacheResponse = await caches.match(request);
return cacheResponse || (await fetch(request));
}
self.addEventListener('fetch', (event: FetchEvent) => {
const {request} = event;
const url = new URL(request.url);
if (url.origin === location.origin) {
event.respondWith(
cacheFallingBackToNetwork(request).catch(reason => {
console.error(reason);
return new Response('', {
status: 500,
statusText: 'offline',
});
})
);
}
});
app.ts
if ('serviceWorker' in navigator) {
void navigator.serviceWorker
.register(new URL('service-worker.ts', import.meta.url), {
type: 'module',
updateViaCache: 'none',
})
.then((registration: ServiceWorkerRegistration) => {
void registration.update().catch(reason => console.error(reason));
});
let refreshing: boolean;
navigator.serviceWorker.addEventListener('controllerchange', function () {
if (refreshing) {
return;
}
refreshing = true;
window.location.reload();
});
}