feat(04-06): implement live-sync SSE vertical slice (LIST-04, D-04/D-10/D-11/D-12)

- Wire publishListEvent fan-out in lists.ts after every write mutation (item:added/updated/deleted, list:updated/deleted)
- Add GET /api/sse/lists scoped endpoint in sse.ts: resolveUserId → 401 on null; getAccessibleListIds → subscribe only to accessible channels; 30s heartbeat; cleanup on disconnect (D-04/T-04-01/T-04-02)
- Create useListSSE.ts: bounded-backoff EventSource wrapper (250ms→500ms→1s→2s→4s→cap 8s); MAX_ATTEMPTS=6; withCredentials:true; close-before-retry prevents reconnect storm (Pitfall 3); invalidates ['list', listId] on open (D-10) and on each event; onStateChange('disconnected') after exhaustion (D-11)
- Create LiveSyncIndicator.tsx: connected=green dot; reconnecting=pulsing muted dot + label; disconnected=red dot + 'Updates paused' (role=alert); correct ARIA per UI-SPEC
- Wire useListSSE + LiveSyncIndicator into ListDetail header; retain refetchInterval:30000 polling fallback (D-12)
- All 8 useListSSE tests pass; all 54 API tests pass; both typechecks pass
- playwright-cli: live update confirmed (eggs item added via API appeared in browser without manual refresh)
This commit is contained in:
Lucas Berger
2026-06-09 13:33:04 -04:00
parent 5a8d1efe1c
commit 1652a68c51
6 changed files with 360 additions and 27 deletions
+1 -1
View File
@@ -313,7 +313,7 @@ describe('useListSSE — D-11 bounded backoff', () => {
})
expect(mockInstances.length).toBe(instanceCountAfterUnmount) // no new connection post-unmount
expect(firstEs.closeCalled ?? (firstEs as unknown as { close: () => void; closeCalled?: boolean }).closeCalled).toBeTruthy()
expect((firstEs as unknown as { closeCalled?: boolean }).closeCalled).toBeTruthy()
})
it('uses withCredentials: true on EventSource (Pitfall 7 — session cookie)', async () => {
+114
View File
@@ -0,0 +1,114 @@
/**
* useListSSE — Bounded-backoff EventSource wrapper for live list sync (LIST-04).
*
* Decisions implemented:
* D-10: Full refetch on reconnect — invalidates ['list', listId] on open.
* D-11: Silent bounded-backoff reconnect (250ms→500ms→1s→2s→4s→cap 8s).
* After MAX_ATTEMPTS failures: onStateChange('disconnected'), stop retrying.
* D-12: Polling fallback (refetchInterval:30000) lives in ListDetail — always active.
*
* Security (Pitfall 7, T-04-01):
* withCredentials: true ensures the OIDC session cookie is sent.
*
* Implementation notes (Pitfall 3 — no reconnect storm):
* On onerror: close the EventSource BEFORE scheduling setTimeout.
* This prevents the browser's built-in reconnect stacking with our manual one.
* Refs (not state) for esRef/attemptsRef/timerRef — avoids re-render loops.
*
* Source: RESEARCH.md Finding 4 verbatim pattern.
*/
import { useEffect, useRef, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
export type SyncState = 'connected' | 'reconnecting' | 'disconnected'
/**
* Backoff schedule per D-11: 250ms → 500ms → 1000ms → 2000ms → 4000ms → cap 8000ms.
* 6 steps → MAX_ATTEMPTS = 6; after exhaustion, state → 'disconnected'.
*/
const BACKOFF_STEPS_MS = [250, 500, 1000, 2000, 4000, 8000]
const MAX_ATTEMPTS = BACKOFF_STEPS_MS.length
export interface UseListSSEOptions {
listId: number
onStateChange: (state: SyncState) => void
}
/**
* Opens a scoped SSE connection to /api/sse/lists and invalidates React Query
* cache on events — driving a full refetch (D-10).
*
* Lifecycle:
* Mount → connect() → EventSource opened
* Open → attemptsRef reset → onStateChange('connected') → invalidate cache
* Event → invalidate ['list', listId]
* Error → es.close() → if attempts left: onStateChange('reconnecting') + setTimeout(connect, backoff)
* else: onStateChange('disconnected') — stop
* Unmount → es.close() + clearTimeout(timerRef)
*/
export function useListSSE({ listId, onStateChange }: UseListSSEOptions): void {
const queryClient = useQueryClient()
const esRef = useRef<EventSource | null>(null)
const attemptsRef = useRef(0)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// handleListChange is stable — invalidate the list on any list-change event
const handleListChange = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['list', listId] })
}, [queryClient, listId])
const connect = useCallback(() => {
// Close any existing connection (prevents stacking)
esRef.current?.close()
const es = new EventSource('/api/sse/lists', { withCredentials: true })
esRef.current = es
// Subscribe to all list-change event types (D-10 — trigger refetch on any change)
es.addEventListener('item:added', handleListChange)
es.addEventListener('item:updated', handleListChange)
es.addEventListener('item:deleted', handleListChange)
es.addEventListener('list:updated', handleListChange)
es.addEventListener('list:deleted', handleListChange)
es.onopen = () => {
// Reset attempt counter on successful open (D-11)
attemptsRef.current = 0
onStateChange('connected')
// Full refetch on (re)connect — D-10: no Last-Event-ID replay, just refetch
queryClient.invalidateQueries({ queryKey: ['list', listId] })
}
es.onerror = () => {
// Close BEFORE scheduling retry — prevents browser auto-reconnect stacking (Pitfall 3)
es.close()
const attempt = attemptsRef.current
if (attempt >= MAX_ATTEMPTS) {
// Backoff exhausted — surface "Updates paused" indicator and stop (D-11)
onStateChange('disconnected')
// D-12 polling fallback (refetchInterval:30000 in ListDetail) keeps data fresh
return
}
onStateChange('reconnecting')
const delay = BACKOFF_STEPS_MS[attempt]
attemptsRef.current = attempt + 1
timerRef.current = setTimeout(connect, delay)
}
}, [listId, queryClient, onStateChange, handleListChange])
useEffect(() => {
connect()
return () => {
// Cleanup: close EventSource + cancel any pending reconnect timer (Pitfall 3)
esRef.current?.close()
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
}, [connect])
}