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
+20 -5
View File
@@ -7,12 +7,27 @@ export class ApiError extends Error {
} }
} }
// Thrown when fetch() itself fails (e.g. offline with no cache entry for this
// request) -- distinct from ApiError, which means the server was reachable
// and responded with an error status. Callers use this to show a quiet
// "not available offline" state instead of a real-error message.
export class NetworkError extends Error {
constructor() {
super('Network unreachable')
}
}
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, { let response: Response
credentials: 'include', try {
headers: { 'Content-Type': 'application/json' }, response = await fetch(path, {
...init, credentials: 'include',
}) headers: { 'Content-Type': 'application/json' },
...init,
})
} catch {
throw new NetworkError()
}
if (!response.ok) { if (!response.ok) {
let detail = response.statusText let detail = response.statusText
+4
View File
@@ -70,3 +70,7 @@
padding: var(--sp-2) var(--sp-4) 0; padding: var(--sp-2) var(--sp-4) 0;
margin: 0; margin: 0;
} }
.chat-pane-note {
color: var(--ds-muted);
}
+17 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { NetworkError } from '../api/client'
import { getRoomMessages } from '../api/rooms' import { getRoomMessages } from '../api/rooms'
import { useChatSocket } from '../ws/useChatSocket' import { useChatSocket } from '../ws/useChatSocket'
import type { ChatMessageEnvelope, Message, MyRoomItem, RoomMember, ServerEnvelope } from '../types' import type { ChatMessageEnvelope, Message, MyRoomItem, RoomMember, ServerEnvelope } from '../types'
@@ -21,12 +22,22 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
const [history, setHistory] = useState<Message[]>([]) const [history, setHistory] = useState<Message[]>([])
const [live, setLive] = useState<ChatMessageEnvelope[]>([]) const [live, setLive] = useState<ChatMessageEnvelope[]>([])
const [wsError, setWsError] = useState<string | null>(null) const [wsError, setWsError] = useState<string | null>(null)
const [historyUnavailableOffline, setHistoryUnavailableOffline] = useState(false)
useEffect(() => { useEffect(() => {
setHistory([]) setHistory([])
setLive([]) setLive([])
setWsError(null) setWsError(null)
getRoomMessages(room.id).then(setHistory).catch((err) => setWsError(String(err))) setHistoryUnavailableOffline(false)
getRoomMessages(room.id)
.then(setHistory)
.catch((err) => {
if (err instanceof NetworkError) {
setHistoryUnavailableOffline(true)
} else {
setWsError(String(err))
}
})
}, [room.id]) }, [room.id])
const onMessage = useCallback((envelope: ServerEnvelope) => { const onMessage = useCallback((envelope: ServerEnvelope) => {
@@ -71,6 +82,11 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
</header> </header>
{wsError && <p className="chat-pane-error">{wsError}</p>} {wsError && <p className="chat-pane-error">{wsError}</p>}
{historyUnavailableOffline && (
<p className="chat-pane-error chat-pane-note">
Message history for this room isn't available offline yet.
</p>
)}
<MessageList messages={[...history, ...live]} members={members} /> <MessageList messages={[...history, ...live]} members={members} />
<Composer roomName={room.name} disabled={!connected} onSend={send} /> <Composer roomName={room.name} disabled={!connected} onSend={send} />
+14 -2
View File
@@ -2,12 +2,18 @@
padding: var(--sp-3) var(--sp-4); padding: var(--sp-3) var(--sp-4);
border-top: 1px solid var(--ds-border); border-top: 1px solid var(--ds-border);
background: var(--ds-surface); background: var(--ds-surface);
display: flex;
flex-direction: column;
gap: 6px;
}
.composer-box {
display: flex; display: flex;
gap: var(--sp-2); gap: var(--sp-2);
align-items: flex-end; align-items: flex-end;
} }
.composer textarea { .composer-box textarea {
flex: 1; flex: 1;
resize: none; resize: none;
background: var(--ds-surface-2); background: var(--ds-surface-2);
@@ -20,7 +26,7 @@
max-height: 120px; max-height: 120px;
} }
.composer textarea:focus { .composer-box textarea:focus {
border-color: var(--ds-accent); border-color: var(--ds-accent);
} }
@@ -42,3 +48,9 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.composer-status {
font-size: 0.76rem;
color: var(--ds-muted);
padding-left: 2px;
}
+30 -23
View File
@@ -1,4 +1,5 @@
import { useRef, useState, type KeyboardEvent } from 'react' import { useRef, useState, type KeyboardEvent } from 'react'
import { useOnlineStatus } from '../hooks/useOnlineStatus'
import './Composer.css' import './Composer.css'
interface ComposerProps { interface ComposerProps {
@@ -10,6 +11,7 @@ interface ComposerProps {
export function Composer({ roomName, disabled, onSend }: ComposerProps) { export function Composer({ roomName, disabled, onSend }: ComposerProps) {
const [value, setValue] = useState('') const [value, setValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const online = useOnlineStatus()
function autoGrow() { function autoGrow() {
const el = textareaRef.current const el = textareaRef.current
@@ -35,29 +37,34 @@ export function Composer({ roomName, disabled, onSend }: ComposerProps) {
return ( return (
<div className="composer"> <div className="composer">
<textarea <div className="composer-box">
ref={textareaRef} <textarea
rows={1} ref={textareaRef}
value={value} rows={1}
disabled={disabled} value={value}
onChange={(e) => { disabled={disabled}
setValue(e.target.value) onChange={(e) => {
autoGrow() setValue(e.target.value)
}} autoGrow()
onKeyDown={handleKeyDown} }}
placeholder={`Message #${roomName}`} onKeyDown={handleKeyDown}
/> placeholder={disabled ? (online ? 'Connecting…' : "You're offline") : `Message #${roomName}`}
<button />
type="button" <button
className="composer-send" type="button"
onClick={handleSend} className="composer-send"
disabled={disabled || !value.trim()} onClick={handleSend}
aria-label="Send message" disabled={disabled || !value.trim()}
> aria-label="Send message"
<svg width="15" height="15" viewBox="0 0 20 20" aria-hidden="true"> >
<polygon points="2,2 18,10 2,18 6,10" fill="currentColor" /> <svg width="15" height="15" viewBox="0 0 20 20" aria-hidden="true">
</svg> <polygon points="2,2 18,10 2,18 6,10" fill="currentColor" />
</button> </svg>
</button>
</div>
{disabled && (
<div className="composer-status">{online ? 'Connecting…' : "You're offline — messages can't be sent right now"}</div>
)}
</div> </div>
) )
} }
+10
View File
@@ -0,0 +1,10 @@
.offline-banner {
flex: none;
background: color-mix(in srgb, var(--ds-highlight) 16%, var(--ds-surface));
color: var(--ds-text);
border-bottom: 1px solid var(--ds-border);
font-size: 0.8rem;
font-weight: 600;
text-align: center;
padding: 6px var(--sp-4);
}
+14
View File
@@ -0,0 +1,14 @@
import { useOnlineStatus } from '../hooks/useOnlineStatus'
import './OfflineBanner.css'
export function OfflineBanner() {
const online = useOnlineStatus()
if (online) return null
return (
<div className="offline-banner" role="status">
You're offline showing cached data.
</div>
)
}
+8
View File
@@ -103,3 +103,11 @@
color: var(--ds-muted); color: var(--ds-muted);
text-transform: uppercase; text-transform: uppercase;
} }
.sidebar-offline-note {
color: var(--ds-muted);
font-size: 0.82rem;
padding: var(--sp-4);
line-height: 1.5;
margin: 0;
}
+21 -11
View File
@@ -11,6 +11,7 @@ interface SidebarProps {
onOpenBrowse: () => void onOpenBrowse: () => void
onOpenInvites: () => void onOpenInvites: () => void
inviteCount: number inviteCount: number
unavailableOffline?: boolean
} }
export function Sidebar({ export function Sidebar({
@@ -22,6 +23,7 @@ export function Sidebar({
onOpenBrowse, onOpenBrowse,
onOpenInvites, onOpenInvites,
inviteCount, inviteCount,
unavailableOffline,
}: SidebarProps) { }: SidebarProps) {
const query = searchQuery.trim().toLowerCase() const query = searchQuery.trim().toLowerCase()
const filtered = query ? rooms.filter((r) => r.name.toLowerCase().includes(query)) : rooms const filtered = query ? rooms.filter((r) => r.name.toLowerCase().includes(query)) : rooms
@@ -67,17 +69,25 @@ export function Sidebar({
Browse rooms Browse rooms
</button> </button>
{filtered.length > 0 && <div className="sidebar-section-label">Rooms</div>} {unavailableOffline ? (
<nav> <p className="sidebar-offline-note">
{filtered.map((room, i) => ( Your rooms aren't available offline yet. Reconnect to load them.
<RoomRow </p>
key={room.id} ) : (
room={room} <>
colorIndex={i} {filtered.length > 0 && <div className="sidebar-section-label">Rooms</div>}
active={room.id === activeRoomId} <nav>
/> {filtered.map((room, i) => (
))} <RoomRow
</nav> key={room.id}
room={room}
colorIndex={i}
active={room.id === activeRoomId}
/>
))}
</nav>
</>
)}
</div> </div>
</aside> </aside>
) )
+35 -5
View File
@@ -1,11 +1,13 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import * as authApi from '../api/auth' 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' import type { User } from '../types'
interface AuthContextValue { interface AuthContextValue {
user: User | null user: User | null
loading: boolean loading: boolean
offline: boolean
login: (usernameOrEmail: string, password: string) => Promise<void> login: (usernameOrEmail: string, password: string) => Promise<void>
logout: () => Promise<void> logout: () => Promise<void>
} }
@@ -15,30 +17,58 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [offline, setOffline] = useState(false)
useEffect(() => { useEffect(() => {
authApi authApi
.me() .me()
.then(setUser) .then((u) => {
setUser(u)
setOffline(false)
saveLastUser(u)
})
.catch((err) => { .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) console.error('Failed to load current user', err)
} }
const cached = loadLastUser()
setUser(cached)
setOffline(cached !== null)
}) })
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
async function login(usernameOrEmail: string, password: string) { 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() { async function logout() {
await authApi.logout() await authApi.logout()
setUser(null) setUser(null)
clearLastUser()
} }
return ( return (
<AuthContext.Provider value={{ user, loading, login, logout }}> <AuthContext.Provider value={{ user, loading, offline, login, logout }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react'
// navigator.onLine reflects the OS network interface, not whether our
// backend is actually reachable -- it can be true when the API is down. This
// is still a useful fast/cheap signal for a banner; the WebSocket
// `connected` state (see useChatSocket) is the more reliable one for
// whether live messaging actually works right now.
export function useOnlineStatus(): boolean {
const [online, setOnline] = useState(() => navigator.onLine)
useEffect(() => {
const goOnline = () => setOnline(true)
const goOffline = () => setOnline(false)
window.addEventListener('online', goOnline)
window.addEventListener('offline', goOffline)
return () => {
window.removeEventListener('online', goOnline)
window.removeEventListener('offline', goOffline)
}
}, [])
return online
}
+35
View File
@@ -0,0 +1,35 @@
import type { User } from '../types'
// A minimal, non-sensitive "who was I last logged in as" cache, so the app
// shell can render offline (ProtectedRoute has something to show) even
// though GET /api/auth/me is intentionally NetworkOnly and can't confirm the
// session while offline. This never grants access to anything real -- every
// server-side action still re-checks the actual session cookie, so a stale
// or wrong cached user here can, at worst, show cached UI; it can't act.
const KEY = 'kit_last_user'
export function saveLastUser(user: User): void {
try {
localStorage.setItem(KEY, JSON.stringify(user))
} catch {
// storage unavailable (private browsing, quota) -- offline fallback
// just won't work this session, not fatal.
}
}
export function loadLastUser(): User | null {
try {
const raw = localStorage.getItem(KEY)
return raw ? (JSON.parse(raw) as User) : null
} catch {
return null
}
}
export function clearLastUser(): void {
try {
localStorage.removeItem(KEY)
} catch {
// ignore
}
}
+17 -3
View File
@@ -1,11 +1,13 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { NetworkError } from '../api/client'
import { listMyInvites } from '../api/invites' import { listMyInvites } from '../api/invites'
import { listMyRooms, listRoomMembers } from '../api/rooms' import { listMyRooms, listRoomMembers } from '../api/rooms'
import { BrowseRoomsModal } from '../components/BrowseRoomsModal' import { BrowseRoomsModal } from '../components/BrowseRoomsModal'
import { ChatPane } from '../components/ChatPane' import { ChatPane } from '../components/ChatPane'
import { InvitesModal } from '../components/InvitesModal' import { InvitesModal } from '../components/InvitesModal'
import { NewRoomModal } from '../components/NewRoomModal' import { NewRoomModal } from '../components/NewRoomModal'
import { OfflineBanner } from '../components/OfflineBanner'
import { RoomInfoPanel } from '../components/RoomInfoPanel' import { RoomInfoPanel } from '../components/RoomInfoPanel'
import { Sidebar } from '../components/Sidebar' import { Sidebar } from '../components/Sidebar'
import { TopBar } from '../components/TopBar' import { TopBar } from '../components/TopBar'
@@ -27,13 +29,23 @@ export function ChatShellPage() {
const [inviteCount, setInviteCount] = useState(0) const [inviteCount, setInviteCount] = useState(0)
const [infoOpen, setInfoOpen] = useState(false) const [infoOpen, setInfoOpen] = useState(false)
const [modal, setModal] = useState<ModalKind>(null) const [modal, setModal] = useState<ModalKind>(null)
const [roomsUnavailableOffline, setRoomsUnavailableOffline] = useState(false)
const activeRoom = rooms.find((r) => r.id === roomId) const activeRoom = rooms.find((r) => r.id === roomId)
const refreshRooms = useCallback(async () => { const refreshRooms = useCallback(async () => {
const list = await listMyRooms() try {
setRooms(list) const list = await listMyRooms()
return list setRooms(list)
setRoomsUnavailableOffline(false)
return list
} catch (err) {
if (err instanceof NetworkError) {
setRoomsUnavailableOffline(true)
return []
}
throw err
}
}, []) }, [])
const refreshMembers = useCallback(() => { const refreshMembers = useCallback(() => {
@@ -62,6 +74,7 @@ export function ChatShellPage() {
return ( return (
<div className="chat-shell"> <div className="chat-shell">
<TopBar /> <TopBar />
<OfflineBanner />
<div className="chat-shell-body"> <div className="chat-shell-body">
{(!isMobile || !roomId) && ( {(!isMobile || !roomId) && (
<Sidebar <Sidebar
@@ -73,6 +86,7 @@ export function ChatShellPage() {
onOpenBrowse={() => setModal('browse')} onOpenBrowse={() => setModal('browse')}
onOpenInvites={() => setModal('invites')} onOpenInvites={() => setModal('invites')}
inviteCount={inviteCount} inviteCount={inviteCount}
unavailableOffline={roomsUnavailableOffline && rooms.length === 0}
/> />
)} )}
+85 -3
View File
@@ -2,6 +2,11 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
const READ_CACHE_EXPIRATION = {
maxEntries: 50,
maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
}
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@@ -13,8 +18,8 @@ export default defineConfig({
short_name: 'Talking', short_name: 'Talking',
start_url: '/', start_url: '/',
display: 'standalone', display: 'standalone',
background_color: '#ffffff', background_color: '#07080f', // --ds-void
theme_color: '#111111', theme_color: '#101030', // --ds-surface
icons: [ icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' }, { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' }, { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
@@ -28,9 +33,71 @@ export default defineConfig({
}, },
workbox: { workbox: {
navigateFallbackDenylist: [/^\/api/, /^\/ws/], navigateFallbackDenylist: [/^\/api/, /^\/ws/],
// urlPattern uses function matchers against url.pathname rather than
// RegExp (which Workbox tests against the *full href*, origin
// included -- a `^/api/` anchor would silently never match).
runtimeCaching: [ runtimeCaching: [
// Never serve a stale cached "who am I" response.
{ {
urlPattern: /^\/api\//, urlPattern: ({ url }) => url.pathname.startsWith('/api/auth/'),
handler: 'NetworkOnly',
},
// Cache-and-refresh: show the last known list/history immediately,
// update from the network in the background. Routes default to
// matching GET only, so mutations to these same paths are
// untouched and still go straight to network.
{
urlPattern: ({ url }) => url.pathname === '/api/rooms/mine',
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-rooms-mine',
expiration: READ_CACHE_EXPIRATION,
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: ({ url }) => url.pathname === '/api/rooms',
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-rooms-open',
expiration: READ_CACHE_EXPIRATION,
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: ({ url }) =>
/^\/api\/rooms\/[^/]+\/messages$/.test(url.pathname),
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-room-messages',
expiration: READ_CACHE_EXPIRATION,
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: ({ url }) =>
/^\/api\/rooms\/[^/]+\/members$/.test(url.pathname),
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-room-members',
expiration: READ_CACHE_EXPIRATION,
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: ({ url }) => url.pathname === '/api/invites/mine',
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-invites-mine',
expiration: READ_CACHE_EXPIRATION,
cacheableResponse: { statuses: [0, 200] },
},
},
// Defensive default: anything else under /api/ (including any
// future GET endpoint) stays network-only until explicitly opted
// in above.
{
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
handler: 'NetworkOnly', handler: 'NetworkOnly',
}, },
], ],
@@ -49,4 +116,19 @@ export default defineConfig({
}, },
}, },
}, },
// `vite preview` doesn't inherit `server.proxy` -- needed to exercise the
// real production service worker (only registered against a built
// bundle, not `vite dev`) against the actual backend.
preview: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
}) })