Phase 3: PWA offline caching (frontend only)

Adds a real Workbox runtime-caching strategy on top of the Phase 1 app-shell
precache: StaleWhileRevalidate (cache-and-refresh) for the five read
endpoints (rooms/mine, open rooms, room messages, room members, invites/mine)
with a bounded/expiring cache per endpoint, while /api/auth/* and all
mutations stay network-only. An OfflineBanner (navigator.onLine-driven) and
a clearer Composer status line ("Connecting..." vs "You're offline") surface
what's actually happening; api/client.ts gains a NetworkError distinct from
ApiError so a genuine cache-miss-while-offline shows a quiet empty state
instead of a red error.

Manual offline testing (backend stopped, `vite preview` against the real
production service worker) surfaced a real gap the plan hadn't accounted
for: GET /api/auth/me is intentionally NetworkOnly, but that meant
ProtectedRoute could never confirm a session while offline and always
bounced to /login -- none of the newly-cached room/message data was ever
reachable. Fixed by caching a minimal, non-sensitive "last known user" in
localStorage (lib/lastUser.ts) and having AuthContext fall back to it for
any *unconfirmed* auth check (network failure, or a down backend answering
through a live reverse proxy with its own 502/503/504 -- both happen in
real deployments, not just literal airplane-mode). Only a server-confirmed
401 still clears it and signs the user out; every real action still
re-checks the actual session cookie server-side, so this can't grant
anything -- it only keeps cached UI reachable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 21:34:26 -06:00
co-authored by Claude Sonnet 5
parent e9fcb9fea2
commit aeb2f3f6a5
14 changed files with 333 additions and 53 deletions
+35 -5
View File
@@ -1,11 +1,13 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import * as authApi from '../api/auth'
import { ApiError } from '../api/client'
import { ApiError, NetworkError } from '../api/client'
import { clearLastUser, loadLastUser, saveLastUser } from '../lib/lastUser'
import type { User } from '../types'
interface AuthContextValue {
user: User | null
loading: boolean
offline: boolean
login: (usernameOrEmail: string, password: string) => Promise<void>
logout: () => Promise<void>
}
@@ -15,30 +17,58 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [offline, setOffline] = useState(false)
useEffect(() => {
authApi
.me()
.then(setUser)
.then((u) => {
setUser(u)
setOffline(false)
saveLastUser(u)
})
.catch((err) => {
if (!(err instanceof ApiError && err.status === 401)) {
// A confirmed 401 means the server is reachable and says "not
// logged in" -- that's the only case that should actually clear the
// cached identity. Everything else (a real network failure, or the
// backend being down behind a reverse proxy that answers with its
// own 502/503/504 -- both happen in production, not just literal
// offline) means we simply couldn't get a confirmed answer.
// /api/auth/me is NetworkOnly by design (never trust a stale
// "who am I" as the *source of truth*), but treating "couldn't
// check" the same as "confirmed logged out" would lock users out of
// the cached rooms/messages entirely whenever the backend is
// unreachable. Every real action still re-checks the actual session
// cookie server-side, so falling back here can't grant anything.
if (err instanceof ApiError && err.status === 401) {
clearLastUser()
return
}
if (!(err instanceof NetworkError)) {
console.error('Failed to load current user', err)
}
const cached = loadLastUser()
setUser(cached)
setOffline(cached !== null)
})
.finally(() => setLoading(false))
}, [])
async function login(usernameOrEmail: string, password: string) {
setUser(await authApi.login(usernameOrEmail, password))
const u = await authApi.login(usernameOrEmail, password)
setUser(u)
setOffline(false)
saveLastUser(u)
}
async function logout() {
await authApi.logout()
setUser(null)
clearLastUser()
}
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
<AuthContext.Provider value={{ user, loading, offline, login, logout }}>
{children}
</AuthContext.Provider>
)