Fix stale room/message/member data after leaving and returning (#37)

The service worker cached /api/rooms/mine, /api/rooms, .../messages,
.../members, and /api/invites/mine with StaleWhileRevalidate: serve
the previous cached response immediately, refresh the cache in the
background for next time. That means every repeat visit showed
content one visit behind -- reopening a room after someone messaged
it, or checking a second device, both showed stale data until a
manual reload (which finally picked up the now-revalidated cache).

Only registers in a production build (vite dev never activates it),
which is why this didn't surface during in-browser testing this
session for #26/#27/#34.

Switched all five routes to NetworkFirst: always prefer a live
response, fall back to cache only when the network request itself
fails or times out (genuinely offline), keeping the "readable while
offline" behavior without the staleness.

Verified against a real production build (`vite preview`, the only
way the SW actually registers): sent a message from a second session
against an already-cached room, and the very next fetch showed it
immediately with no staleness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 14:55:30 -06:00
co-authored by Claude Sonnet 5
parent 1567a96e8a
commit 1c2d2e91c1
+23 -6
View File
@@ -3,7 +3,7 @@ import { CacheableResponsePlugin } from 'workbox-cacheable-response'
import { ExpirationPlugin } from 'workbox-expiration' import { ExpirationPlugin } from 'workbox-expiration'
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching' import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
import { NavigationRoute, registerRoute } from 'workbox-routing' import { NavigationRoute, registerRoute } from 'workbox-routing'
import { NetworkOnly, StaleWhileRevalidate } from 'workbox-strategies' import { NetworkFirst, NetworkOnly } from 'workbox-strategies'
declare let self: ServiceWorkerGlobalScope declare let self: ServiceWorkerGlobalScope
@@ -20,6 +20,18 @@ registerRoute(
const READ_CACHE_EXPIRATION = { maxEntries: 50, maxAgeSeconds: 7 * 24 * 60 * 60 } const READ_CACHE_EXPIRATION = { maxEntries: 50, maxAgeSeconds: 7 * 24 * 60 * 60 }
const cacheableResponse = new CacheableResponsePlugin({ statuses: [0, 200] }) const cacheableResponse = new CacheableResponsePlugin({ statuses: [0, 200] })
// Always prefer a live network response over the cache -- these routes back
// an actively-updating chat, so "stale" isn't an acceptable default the way
// it can be for e.g. static assets. StaleWhileRevalidate was tried here
// first, but it serves the *previous* cached response immediately and only
// refreshes the cache in the background for next time, which means every
// repeat visit shows content that's one visit behind until a manual reload
// (confirmed as the cause of #37 -- messages/rooms/members looking stale
// after leaving and returning to a room, or after another device's update).
// NetworkFirst keeps the same "readable while offline" behavior (falls back
// to cache only when the network request itself fails or times out) without
// that staleness while online.
const NETWORK_TIMEOUT_SECONDS = 4
// Ported from Phase 3's vite.config.ts `workbox.runtimeCaching` -- that // Ported from Phase 3's vite.config.ts `workbox.runtimeCaching` -- that
// option only applies to the generateSW strategy, so with a hand-written // option only applies to the generateSW strategy, so with a hand-written
@@ -29,36 +41,41 @@ registerRoute(({ url }) => url.pathname.startsWith('/api/auth/'), new NetworkOnl
registerRoute( registerRoute(
({ url }) => url.pathname === '/api/rooms/mine', ({ url }) => url.pathname === '/api/rooms/mine',
new StaleWhileRevalidate({ new NetworkFirst({
cacheName: 'api-rooms-mine', cacheName: 'api-rooms-mine',
networkTimeoutSeconds: NETWORK_TIMEOUT_SECONDS,
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)], plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
}), }),
) )
registerRoute( registerRoute(
({ url }) => url.pathname === '/api/rooms', ({ url }) => url.pathname === '/api/rooms',
new StaleWhileRevalidate({ new NetworkFirst({
cacheName: 'api-rooms-open', cacheName: 'api-rooms-open',
networkTimeoutSeconds: NETWORK_TIMEOUT_SECONDS,
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)], plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
}), }),
) )
registerRoute( registerRoute(
({ url }) => /^\/api\/rooms\/[^/]+\/messages$/.test(url.pathname), ({ url }) => /^\/api\/rooms\/[^/]+\/messages$/.test(url.pathname),
new StaleWhileRevalidate({ new NetworkFirst({
cacheName: 'api-room-messages', cacheName: 'api-room-messages',
networkTimeoutSeconds: NETWORK_TIMEOUT_SECONDS,
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)], plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
}), }),
) )
registerRoute( registerRoute(
({ url }) => /^\/api\/rooms\/[^/]+\/members$/.test(url.pathname), ({ url }) => /^\/api\/rooms\/[^/]+\/members$/.test(url.pathname),
new StaleWhileRevalidate({ new NetworkFirst({
cacheName: 'api-room-members', cacheName: 'api-room-members',
networkTimeoutSeconds: NETWORK_TIMEOUT_SECONDS,
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)], plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
}), }),
) )
registerRoute( registerRoute(
({ url }) => url.pathname === '/api/invites/mine', ({ url }) => url.pathname === '/api/invites/mine',
new StaleWhileRevalidate({ new NetworkFirst({
cacheName: 'api-invites-mine', cacheName: 'api-invites-mine',
networkTimeoutSeconds: NETWORK_TIMEOUT_SECONDS,
plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)], plugins: [cacheableResponse, new ExpirationPlugin(READ_CACHE_EXPIRATION)],
}), }),
) )