Auto-reload open tabs on SW activate so returning users see fresh build immediately

Regression-of: 5b8d2d5c

The previous SW eviction commit wiped caches and called clients.claim()
on activate, but that only changes which SW handles future fetches — it
does not re-render a tab that already finished loading the stale bundle.
In practice, returning users had to manually close and reopen the tab
before seeing the new build.

Fix: after clients.claim(), iterate self.clients.matchAll({ type: 'window' })
and call client.navigate(client.url) on each one. Since this SW has no
fetch handler, the navigation falls through to the network and the tab
re-renders against the fresh index.html + hashed bundle.

Caveats:
- Users mid-interaction (typing a post, scrolling) lose their unsaved
  state. Acceptable trade — the alternative is they stay on a broken
  cached bundle indefinitely.
- Fires exactly once per user (only on the install -> activate transition
  for a byte-different /sw.js). No reload loop.

Also corrected the misleading comment on the main.tsx registration: that
registration is forward-looking insurance for future cache busts, not the
mechanism that evicts the old SW. The browser's own SW update check is
what re-fetches /sw.js out-of-band; our in-page JS never runs on a tab
the old precache SW is controlling.
This commit is contained in:
Alex Gleason
2026-05-17 14:05:20 -05:00
parent 5b8d2d5c06
commit 119307d13b
2 changed files with 43 additions and 19 deletions
+26 -7
View File
@@ -57,14 +57,21 @@ self.addEventListener('notificationclick', (event) => {
// --- Activate immediately ---
//
// On activate, nuke every Cache Storage entry. A previous version of Agora
// deployed a precaching service worker (Workbox-style) that's still serving
// stale HTML/JS to returning users on the same origin. Wiping caches here
// means the first request a returning user makes after this SW takes over
// will hit the network and get the new build.
// On activate:
// 1. Wipe every Cache Storage entry. A previous version of Agora deployed
// a precaching service worker (Workbox-style) that's still serving stale
// HTML/JS to returning users on this origin. Clearing caches means future
// requests bypass anything the old SW left behind.
// 2. Take control of all open clients via clients.claim().
// 3. Force each controlled tab to navigate to its own URL. clients.claim()
// only changes which SW handles future fetches — it does not re-render
// pages that already finished loading. Without the explicit navigate,
// the user is stuck on the old rendered bundle until they manually
// close and reopen the tab. Since this SW has no fetch handler, the
// navigation falls through to the network and gets the new build.
//
// This SW itself does not intercept fetches (no 'fetch' handler), so it
// never repopulates a cache only push notifications are handled below.
// This SW has no 'fetch' handler, so it never repopulates a cache — push
// notifications are the only thing it intercepts.
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
@@ -73,6 +80,18 @@ self.addEventListener('activate', (event) => {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
await self.clients.claim();
// Soft-reload every open same-origin tab so it picks up the fresh
// index.html + hashed bundle from the network. WindowClient.navigate()
// is same-origin-only by spec, which is exactly what we want.
const windowClients = await self.clients.matchAll({ type: 'window' });
await Promise.all(
windowClients.map((client) =>
'navigate' in client
? client.navigate(client.url).catch(() => {})
: Promise.resolve(),
),
);
})(),
);
});
+17 -12
View File
@@ -87,21 +87,26 @@ requestAnimationFrame(() => {
document.getElementById('preloader')?.remove();
});
// ─── Service worker takeover (web only) ──────────────────────────────────────
// ─── Service worker registration (web only) ─────────────────────────────────
//
// A previous version of Agora deployed at this origin shipped a precaching
// service worker that's still serving stale HTML/JS to returning users. The
// SW we ship now (public/sw.js) has no fetch handler and nukes every cache
// on activate, so as soon as the browser installs it, returning users start
// getting fresh builds again.
// Register /sw.js unconditionally on web. The SW itself (public/sw.js) has
// no fetch handler and wipes caches on activate — see that file for the
// stale-SW eviction story.
//
// usePushNotifications() also registers /sw.js, but only when the user
// visits the notification settings page — that's not good enough to evict
// the old SW for everyone. We register here on every web page load so the
// new SW takes over for all visitors, regardless of whether they use push.
// This registration does NOT fix the stale-SW problem on its own: returning
// users with the old precache SW never run any of our new JS, because the
// old SW serves the old bundle from cache. The browser evicts the old SW
// out-of-band by re-fetching /sw.js on its own update schedule, and the
// new SW's activate handler does the actual cache wipe + tab reload.
//
// Native (Capacitor) skips this — the bundled web assets are served from
// the local filesystem and there's no stale SW on the origin to evict.
// What this registration buys us is forward-looking insurance: it ensures
// every web visitor has a SW in place, so the next time we need to ship an
// emergency cache bust via /sw.js, there's something for the browser to
// update. Without it, only push-enabled users (who hit
// usePushNotifications) would ever have a SW registered.
//
// Native (Capacitor) skips this — assets are served from the local
// filesystem, no SW involved.
if (!Capacitor.isNativePlatform() && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch((err) => {