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
+16 -1
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> {
const response = await fetch(path, {
let response: Response
try {
response = await fetch(path, {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
...init,
})
} catch {
throw new NetworkError()
}
if (!response.ok) {
let detail = response.statusText
+4
View File
@@ -70,3 +70,7 @@
padding: var(--sp-2) var(--sp-4) 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 { useNavigate } from 'react-router-dom'
import { NetworkError } from '../api/client'
import { getRoomMessages } from '../api/rooms'
import { useChatSocket } from '../ws/useChatSocket'
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 [live, setLive] = useState<ChatMessageEnvelope[]>([])
const [wsError, setWsError] = useState<string | null>(null)
const [historyUnavailableOffline, setHistoryUnavailableOffline] = useState(false)
useEffect(() => {
setHistory([])
setLive([])
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])
const onMessage = useCallback((envelope: ServerEnvelope) => {
@@ -71,6 +82,11 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
</header>
{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} />
<Composer roomName={room.name} disabled={!connected} onSend={send} />
+14 -2
View File
@@ -2,12 +2,18 @@
padding: var(--sp-3) var(--sp-4);
border-top: 1px solid var(--ds-border);
background: var(--ds-surface);
display: flex;
flex-direction: column;
gap: 6px;
}
.composer-box {
display: flex;
gap: var(--sp-2);
align-items: flex-end;
}
.composer textarea {
.composer-box textarea {
flex: 1;
resize: none;
background: var(--ds-surface-2);
@@ -20,7 +26,7 @@
max-height: 120px;
}
.composer textarea:focus {
.composer-box textarea:focus {
border-color: var(--ds-accent);
}
@@ -42,3 +48,9 @@
opacity: 0.5;
cursor: not-allowed;
}
.composer-status {
font-size: 0.76rem;
color: var(--ds-muted);
padding-left: 2px;
}
+8 -1
View File
@@ -1,4 +1,5 @@
import { useRef, useState, type KeyboardEvent } from 'react'
import { useOnlineStatus } from '../hooks/useOnlineStatus'
import './Composer.css'
interface ComposerProps {
@@ -10,6 +11,7 @@ interface ComposerProps {
export function Composer({ roomName, disabled, onSend }: ComposerProps) {
const [value, setValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const online = useOnlineStatus()
function autoGrow() {
const el = textareaRef.current
@@ -35,6 +37,7 @@ export function Composer({ roomName, disabled, onSend }: ComposerProps) {
return (
<div className="composer">
<div className="composer-box">
<textarea
ref={textareaRef}
rows={1}
@@ -45,7 +48,7 @@ export function Composer({ roomName, disabled, onSend }: ComposerProps) {
autoGrow()
}}
onKeyDown={handleKeyDown}
placeholder={`Message #${roomName}`}
placeholder={disabled ? (online ? 'Connecting…' : "You're offline") : `Message #${roomName}`}
/>
<button
type="button"
@@ -59,5 +62,9 @@ export function Composer({ roomName, disabled, onSend }: ComposerProps) {
</svg>
</button>
</div>
{disabled && (
<div className="composer-status">{online ? 'Connecting…' : "You're offline — messages can't be sent right now"}</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);
text-transform: uppercase;
}
.sidebar-offline-note {
color: var(--ds-muted);
font-size: 0.82rem;
padding: var(--sp-4);
line-height: 1.5;
margin: 0;
}
+10
View File
@@ -11,6 +11,7 @@ interface SidebarProps {
onOpenBrowse: () => void
onOpenInvites: () => void
inviteCount: number
unavailableOffline?: boolean
}
export function Sidebar({
@@ -22,6 +23,7 @@ export function Sidebar({
onOpenBrowse,
onOpenInvites,
inviteCount,
unavailableOffline,
}: SidebarProps) {
const query = searchQuery.trim().toLowerCase()
const filtered = query ? rooms.filter((r) => r.name.toLowerCase().includes(query)) : rooms
@@ -67,6 +69,12 @@ export function Sidebar({
Browse rooms
</button>
{unavailableOffline ? (
<p className="sidebar-offline-note">
Your rooms aren't available offline yet. Reconnect to load them.
</p>
) : (
<>
{filtered.length > 0 && <div className="sidebar-section-label">Rooms</div>}
<nav>
{filtered.map((room, i) => (
@@ -78,6 +86,8 @@ export function Sidebar({
/>
))}
</nav>
</>
)}
</div>
</aside>
)
+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>
)
+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
}
}
+14
View File
@@ -1,11 +1,13 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { NetworkError } from '../api/client'
import { listMyInvites } from '../api/invites'
import { listMyRooms, listRoomMembers } from '../api/rooms'
import { BrowseRoomsModal } from '../components/BrowseRoomsModal'
import { ChatPane } from '../components/ChatPane'
import { InvitesModal } from '../components/InvitesModal'
import { NewRoomModal } from '../components/NewRoomModal'
import { OfflineBanner } from '../components/OfflineBanner'
import { RoomInfoPanel } from '../components/RoomInfoPanel'
import { Sidebar } from '../components/Sidebar'
import { TopBar } from '../components/TopBar'
@@ -27,13 +29,23 @@ export function ChatShellPage() {
const [inviteCount, setInviteCount] = useState(0)
const [infoOpen, setInfoOpen] = useState(false)
const [modal, setModal] = useState<ModalKind>(null)
const [roomsUnavailableOffline, setRoomsUnavailableOffline] = useState(false)
const activeRoom = rooms.find((r) => r.id === roomId)
const refreshRooms = useCallback(async () => {
try {
const list = await listMyRooms()
setRooms(list)
setRoomsUnavailableOffline(false)
return list
} catch (err) {
if (err instanceof NetworkError) {
setRoomsUnavailableOffline(true)
return []
}
throw err
}
}, [])
const refreshMembers = useCallback(() => {
@@ -62,6 +74,7 @@ export function ChatShellPage() {
return (
<div className="chat-shell">
<TopBar />
<OfflineBanner />
<div className="chat-shell-body">
{(!isMobile || !roomId) && (
<Sidebar
@@ -73,6 +86,7 @@ export function ChatShellPage() {
onOpenBrowse={() => setModal('browse')}
onOpenInvites={() => setModal('invites')}
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 { VitePWA } from 'vite-plugin-pwa'
const READ_CACHE_EXPIRATION = {
maxEntries: 50,
maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
}
// https://vite.dev/config/
export default defineConfig({
plugins: [
@@ -13,8 +18,8 @@ export default defineConfig({
short_name: 'Talking',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#111111',
background_color: '#07080f', // --ds-void
theme_color: '#101030', // --ds-surface
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
@@ -28,9 +33,71 @@ export default defineConfig({
},
workbox: {
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: [
// 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',
},
],
@@ -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,
},
},
},
})