From 1dcfd1c21eb92061e71584caab9ef3b41f2103e4 Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Tue, 9 Dec 2025 04:21:01 -0500 Subject: [PATCH 01/19] initiall fe sse plan --- .../implementation-plans/sse-communication.md | 1557 +++++++++++++++++ 1 file changed, 1557 insertions(+) create mode 100644 docs/implementation-plans/sse-communication.md diff --git a/docs/implementation-plans/sse-communication.md b/docs/implementation-plans/sse-communication.md new file mode 100644 index 00000000..0e01e5e5 --- /dev/null +++ b/docs/implementation-plans/sse-communication.md @@ -0,0 +1,1557 @@ +# SSE Communication - Frontend Implementation Plan + +## Overview + +This document outlines the frontend implementation for Server-Sent Events (SSE) infrastructure to enable real-time, unidirectional communication from backend to frontend. The implementation leverages existing codebase patterns (SWR, Zustand, custom hooks) to provide automatic cache invalidation when SSE events are received. + +**Design Philosophy: THE SIMPLEST SOLUTION** + +Instead of manually updating component state when SSE events arrive, we **revalidate SWR caches** and let SWR refetch the data automatically. This approach: +- Requires zero changes to existing components +- Leverages existing infrastructure (SWR, EntityApi) +- Maintains consistency with REST API patterns +- Provides automatic optimistic updates +- Follows established codebase conventions + +--- + +## Architecture Overview + +```mermaid +graph TB + subgraph "Frontend Application" + App[App Root] + Providers[Providers Component] + SseProvider[SSE Provider] + SseConnection[SSE Connection Hook] + SseInvalidation[Cache Invalidation Hook] + SseSystem[System Events Hook] + + Components[Existing Components
No changes needed] + SWR[SWR Cache] + + App --> Providers + Providers --> SseProvider + SseProvider --> SseConnection + SseProvider --> SseInvalidation + SseProvider --> SseSystem + + SseInvalidation -.Revalidate.-> SWR + Components -.Use.-> SWR + SWR -.Refetch.-> Backend + end + + subgraph "Backend" + Backend[SSE Endpoint
/api/sse] + end + + SseConnection -.EventSource.-> Backend + + style SseInvalidation fill:#b3e5fc,stroke:#01579b,stroke-width:2px + style SWR fill:#c8e6c9,stroke:#1b5e20,stroke-width:2px + style Components fill:#fff9c4,stroke:#f57f17,stroke-width:2px +``` + +### Event Flow Sequence + +```mermaid +sequenceDiagram + participant User1 as User 1 Browser + participant User2 as User 2 Browser + participant SSE as SSE Connection + participant Cache as SWR Cache + participant Backend as Backend API + + Note over User2,SSE: SSE Connection Established + User2->>SSE: EventSource /api/sse + SSE->>Backend: GET /sse (session cookie) + Backend-->>SSE: Connection established + + Note over User1,Backend: User 1 Creates Action + User1->>Backend: POST /actions {action data} + Backend-->>User1: 201 Created {action} + Backend->>SSE: SSE Event: action_created + + Note over SSE,Cache: Automatic Cache Invalidation + SSE->>Cache: mutate("/actions?session_id=...") + Cache->>Backend: Refetch GET /actions + Backend-->>Cache: Updated action list + Cache-->>User2: Updated data via useActionList + + Note over User2: User 2 sees new action automatically +``` + +--- + +## Core Design Decisions + +### 1. SWR Cache Invalidation (Not Manual State Updates) + +**Why:** Existing components use SWR hooks (`useActionList`, `useAction`, etc.). Rather than manually updating state, we invalidate the SWR cache when SSE events arrive. SWR automatically refetches the data. + +**Benefits:** +- Zero component changes required +- Consistent with existing patterns +- Automatic deduplication and request batching +- Built-in error handling and retry logic +- Works with existing optimistic updates + +### 2. Strong Typing Throughout (No `any` Types) + +**Why:** TypeScript provides compile-time safety and IntelliSense support. + +**Implementation:** +- Event types match backend exactly (`src/types/sse-events.ts`) +- Type guards for runtime validation +- Generic type parameters for event handlers +- Strict null checks + +### 3. Automatic Reconnection with Exponential Backoff + +**Why:** Network failures are inevitable in production. + +**Implementation:** +- Exponential backoff (1s → 1.5s → 2.25s → ... → 30s max) +- Max 10 reconnection attempts +- Connection state tracked in Zustand store +- Visible in DevTools for debugging + +### 4. Connection State Management via Zustand + +**Why:** Follows existing pattern (auth-store, organization-state-store). + +**Implementation:** +- `SseConnectionStore` tracks connection state +- States: Disconnected, Connecting, Connected, Reconnecting, Error +- Error tracking with timestamps and attempt numbers +- DevTools integration + +### 5. Provider Composition Pattern + +**Why:** Consistent with existing provider architecture. + +**Implementation:** +- `SseConnectionStoreProvider` - Provides connection state +- `SseProvider` - Composes hooks and provides to app +- Added to root `Providers` component + +--- + +## File Structure + +``` +src/ +├── types/ +│ └── sse-events.ts # Event type definitions +│ +├── lib/ +│ ├── stores/ +│ │ └── sse-connection-store.ts # Connection state management +│ │ +│ ├── hooks/ +│ │ ├── use-sse-connection.ts # SSE connection with reconnection +│ │ ├── use-sse-event-handler.ts # Type-safe event handler +│ │ ├── use-sse-cache-invalidation.ts # SWR cache invalidation +│ │ └── use-sse-system-events.ts # System events (force logout) +│ │ +│ └── providers/ +│ ├── sse-connection-store-provider.tsx # Connection store provider +│ └── sse-provider.tsx # Main SSE provider +│ +└── components/ + ├── providers.tsx # Root providers (updated) + └── sse-connection-indicator.tsx # Optional: UI indicator +``` + +--- + +## Implementation Phases + +### Phase 1: Type Definitions + +**File:** `src/types/sse-events.ts` + +Create strongly-typed event definitions matching backend exactly. + +```typescript +import { Id } from './general'; +import { Action } from './action'; +import { Agreement } from './agreement'; +import { OverarchingGoal } from './overarching-goal'; + +/** + * SSE event types - must match backend exactly + */ +export type SseEventType = + | 'action_created' + | 'action_updated' + | 'action_deleted' + | 'agreement_created' + | 'agreement_updated' + | 'agreement_deleted' + | 'goal_created' + | 'goal_updated' + | 'goal_deleted' + | 'force_logout'; + +/** + * SSE event payload wrapper - matches backend serialization + * Backend sends: { type: "action_created", data: { coaching_session_id, action } } + */ +export interface SseEventEnvelope { + type: SseEventType; + data: T; +} + +// Session-scoped events +export interface ActionCreatedData { + coaching_session_id: Id; + action: Action; +} + +export interface ActionUpdatedData { + coaching_session_id: Id; + action: Action; +} + +export interface ActionDeletedData { + coaching_session_id: Id; + action_id: Id; +} + +// Relationship-scoped events +export interface AgreementCreatedData { + coaching_relationship_id: Id; + agreement: Agreement; +} + +export interface AgreementUpdatedData { + coaching_relationship_id: Id; + agreement: Agreement; +} + +export interface AgreementDeletedData { + coaching_relationship_id: Id; + agreement_id: Id; +} + +export interface GoalCreatedData { + coaching_relationship_id: Id; + goal: OverarchingGoal; +} + +export interface GoalUpdatedData { + coaching_relationship_id: Id; + goal: OverarchingGoal; +} + +export interface GoalDeletedData { + coaching_relationship_id: Id; + goal_id: Id; +} + +// System events +export interface ForceLogoutData { + reason: string; +} + +// Union type for all event data +export type SseEventData = + | ActionCreatedData + | ActionUpdatedData + | ActionDeletedData + | AgreementCreatedData + | AgreementUpdatedData + | AgreementDeletedData + | GoalCreatedData + | GoalUpdatedData + | GoalDeletedData + | ForceLogoutData; + +/** + * Type guards for runtime validation + */ +export function isActionCreatedData(data: unknown): data is ActionCreatedData { + if (!data || typeof data !== 'object') return false; + const obj = data as Record; + return ( + typeof obj.coaching_session_id === 'string' && + typeof obj.action === 'object' && + obj.action !== null + ); +} + +export function isActionUpdatedData(data: unknown): data is ActionUpdatedData { + return isActionCreatedData(data); +} + +export function isActionDeletedData(data: unknown): data is ActionDeletedData { + if (!data || typeof data !== 'object') return false; + const obj = data as Record; + return ( + typeof obj.coaching_session_id === 'string' && + typeof obj.action_id === 'string' + ); +} + +export function isForceLogoutData(data: unknown): data is ForceLogoutData { + if (!data || typeof data !== 'object') return false; + const obj = data as Record; + return typeof obj.reason === 'string'; +} + +// TODO: Add type guards for agreements, goals, etc. +``` + +**Why:** +- Compile-time type safety +- IntelliSense support in event handlers +- Runtime validation via type guards +- Single source of truth for event structure + +--- + +### Phase 2: Connection State Store + +**File:** `src/lib/stores/sse-connection-store.ts` + +Create Zustand store for connection state management. + +```typescript +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +export enum SseConnectionState { + Disconnected = 'disconnected', + Connecting = 'connecting', + Connected = 'connected', + Reconnecting = 'reconnecting', + Error = 'error', +} + +interface SseError { + message: string; + timestamp: Date; + attemptNumber: number; +} + +interface SseConnectionStateData { + state: SseConnectionState; + lastError: SseError | null; + reconnectAttempts: number; + lastConnectedAt: Date | null; + lastEventAt: Date | null; +} + +interface SseConnectionActions { + setConnecting: () => void; + setConnected: () => void; + setReconnecting: (attempt: number) => void; + setError: (error: string) => void; + setDisconnected: () => void; + recordEvent: () => void; + resetReconnectAttempts: () => void; +} + +export type SseConnectionStore = SseConnectionStateData & SseConnectionActions; + +const defaultState: SseConnectionStateData = { + state: SseConnectionState.Disconnected, + lastError: null, + reconnectAttempts: 0, + lastConnectedAt: null, + lastEventAt: null, +}; + +export const createSseConnectionStore = () => { + return create()( + devtools( + (set) => ({ + ...defaultState, + + setConnecting: () => { + set({ state: SseConnectionState.Connecting }); + }, + + setConnected: () => { + set({ + state: SseConnectionState.Connected, + lastConnectedAt: new Date(), + reconnectAttempts: 0, + lastError: null, + }); + }, + + setReconnecting: (attempt: number) => { + set({ + state: SseConnectionState.Reconnecting, + reconnectAttempts: attempt, + }); + }, + + setError: (message: string) => { + set((state) => ({ + state: SseConnectionState.Error, + lastError: { + message, + timestamp: new Date(), + attemptNumber: state.reconnectAttempts, + }, + })); + }, + + setDisconnected: () => { + set(defaultState); + }, + + recordEvent: () => { + set({ lastEventAt: new Date() }); + }, + + resetReconnectAttempts: () => { + set({ reconnectAttempts: 0 }); + }, + }), + { name: 'sse-connection-store' } + ) + ); +}; +``` + +**Why:** +- Follows existing Zustand store pattern +- DevTools integration for debugging +- Tracks connection health and errors +- Visible in Redux DevTools + +--- + +### Phase 3: SSE Connection Hook + +**File:** `src/lib/hooks/use-sse-connection.ts` + +Core hook that establishes SSE connection with automatic reconnection. + +```typescript +"use client"; + +import { useEffect, useRef, useCallback } from 'react'; +import { siteConfig } from '@/site.config'; +import { useSseConnectionStore } from '@/lib/providers/sse-connection-store-provider'; +import { SseConnectionState } from '@/lib/stores/sse-connection-store'; + +interface ReconnectionConfig { + maxAttempts: number; + initialDelay: number; + maxDelay: number; + backoffMultiplier: number; +} + +const DEFAULT_RECONNECTION_CONFIG: ReconnectionConfig = { + maxAttempts: 10, + initialDelay: 1000, // 1 second + maxDelay: 30000, // 30 seconds + backoffMultiplier: 1.5, +}; + +/** + * Core SSE connection hook with automatic reconnection and exponential backoff. + * + * Features: + * - Automatic reconnection with exponential backoff + * - Connection state management via Zustand store + * - Graceful cleanup on unmount + * - Event listener management + * + * @returns EventSource instance or null if not connected + */ +export function useSseConnection() { + const eventSourceRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const mountedRef = useRef(true); + + const { + state, + reconnectAttempts, + setConnecting, + setConnected, + setReconnecting, + setError, + setDisconnected, + resetReconnectAttempts, + } = useSseConnectionStore((store) => ({ + state: store.state, + reconnectAttempts: store.reconnectAttempts, + setConnecting: store.setConnecting, + setConnected: store.setConnected, + setReconnecting: store.setReconnecting, + setError: store.setError, + setDisconnected: store.setDisconnected, + resetReconnectAttempts: store.resetReconnectAttempts, + })); + + const calculateBackoff = useCallback((attempt: number): number => { + const delay = Math.min( + DEFAULT_RECONNECTION_CONFIG.initialDelay * + Math.pow(DEFAULT_RECONNECTION_CONFIG.backoffMultiplier, attempt), + DEFAULT_RECONNECTION_CONFIG.maxDelay + ); + return delay; + }, []); + + const cleanup = useCallback(() => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + }, []); + + const connect = useCallback(() => { + if (!mountedRef.current) return; + + // Close existing connection + cleanup(); + + setConnecting(); + + try { + const es = new EventSource( + `${siteConfig.env.backendServiceURL}/sse`, + { withCredentials: true } + ); + + es.onopen = () => { + if (!mountedRef.current) { + es.close(); + return; + } + console.log('[SSE] Connection established'); + setConnected(); + resetReconnectAttempts(); + }; + + es.onerror = (error) => { + if (!mountedRef.current) return; + + console.error('[SSE] Connection error:', error); + + // EventSource automatically attempts to reconnect on error + // We handle the error state and implement our own backoff + const currentAttempts = reconnectAttempts + 1; + + if (currentAttempts >= DEFAULT_RECONNECTION_CONFIG.maxAttempts) { + setError(`Max reconnection attempts (${DEFAULT_RECONNECTION_CONFIG.maxAttempts}) reached`); + cleanup(); + } else { + setError(`Connection error (attempt ${currentAttempts})`); + setReconnecting(currentAttempts); + + const delay = calculateBackoff(currentAttempts); + console.log(`[SSE] Reconnecting in ${delay}ms...`); + + reconnectTimeoutRef.current = setTimeout(() => { + if (mountedRef.current) { + connect(); + } + }, delay); + } + }; + + eventSourceRef.current = es; + } catch (error) { + console.error('[SSE] Failed to create EventSource:', error); + setError(error instanceof Error ? error.message : 'Unknown error'); + } + }, [ + cleanup, + setConnecting, + setConnected, + setError, + setReconnecting, + resetReconnectAttempts, + reconnectAttempts, + calculateBackoff, + ]); + + // Establish connection on mount + useEffect(() => { + mountedRef.current = true; + connect(); + + // Cleanup on unmount + return () => { + mountedRef.current = false; + cleanup(); + setDisconnected(); + }; + }, [connect, cleanup, setDisconnected]); + + return eventSourceRef.current; +} +``` + +**Why:** +- Automatic reconnection prevents permanent disconnection +- Exponential backoff prevents server overload +- Cleanup prevents memory leaks +- Connection state visible in DevTools + +--- + +### Phase 4: Event Handler Hook + +**File:** `src/lib/hooks/use-sse-event-handler.ts` + +Type-safe hook for handling SSE events. + +```typescript +"use client"; + +import { useEffect, useRef } from 'react'; +import type { SseEventType, SseEventData } from '@/types/sse-events'; +import { useSseConnectionStore } from '@/lib/providers/sse-connection-store-provider'; +import { transformEntityDates } from '@/types/general'; + +/** + * Type-safe SSE event handler hook. + * + * Features: + * - Strong typing (no `any` types) + * - Automatic event listener cleanup + * - Date transformation for consistency with REST API + * - Connection state tracking + * - Runtime type validation (optional) + * + * @template T - The expected event data type + * @param eventSource - The EventSource instance + * @param eventType - The SSE event type to listen for + * @param handler - Callback function to handle the event + * @param typeGuard - Optional runtime type guard for validation + */ +export function useSseEventHandler( + eventSource: EventSource | null, + eventType: SseEventType, + handler: (data: T) => void, + typeGuard?: (data: unknown) => data is T +) { + // Use ref to avoid recreating listener on every render + const handlerRef = useRef(handler); + const recordEvent = useSseConnectionStore((store) => store.recordEvent); + + // Update ref when handler changes + useEffect(() => { + handlerRef.current = handler; + }, [handler]); + + useEffect(() => { + if (!eventSource) return; + + const listener = (e: MessageEvent) => { + try { + // Parse event data + const parsed = JSON.parse(e.data); + + // Transform dates (consistent with REST API) + const transformed = transformEntityDates(parsed); + + // Optional runtime validation + if (typeGuard && !typeGuard(transformed.data)) { + console.error( + `[SSE] Type guard failed for event ${eventType}:`, + transformed.data + ); + return; + } + + // Record event for connection health tracking + recordEvent(); + + // Invoke handler with typed data + handlerRef.current(transformed.data as T); + } catch (error) { + console.error(`[SSE] Failed to parse ${eventType} event:`, error, e.data); + } + }; + + eventSource.addEventListener(eventType, listener); + + return () => { + eventSource.removeEventListener(eventType, listener); + }; + }, [eventSource, eventType, typeGuard, recordEvent]); +} +``` + +**Why:** +- Type-safe (no `any` types) +- Date transformation for consistency +- Automatic cleanup prevents memory leaks +- Optional type guards for runtime validation + +--- + +### Phase 5: SWR Cache Invalidation Hook (THE KEY IMPROVEMENT) + +**File:** `src/lib/hooks/use-sse-cache-invalidation.ts` + +This is the core architectural improvement over manual state updates. + +```typescript +"use client"; + +import { useCallback } from 'react'; +import { useSWRConfig } from 'swr'; +import { siteConfig } from '@/site.config'; +import { useSseEventHandler } from './use-sse-event-handler'; +import type { + ActionCreatedData, + ActionUpdatedData, + ActionDeletedData, + AgreementCreatedData, + AgreementUpdatedData, + AgreementDeletedData, + GoalCreatedData, + GoalUpdatedData, + GoalDeletedData, +} from '@/types/sse-events'; + +/** + * SSE cache invalidation hook that automatically revalidates SWR caches + * when SSE events are received. + * + * THIS IS THE SIMPLEST SOLUTION: + * - Rather than manually updating component state, we invalidate SWR caches + * - SWR automatically refetches the data + * - Existing components using useActionList, useAction, etc. require NO changes + * - Consistent with existing REST API patterns + * - Automatic deduplication and batching + * + * @param eventSource - The SSE connection + */ +export function useSseCacheInvalidation(eventSource: EventSource | null) { + const { mutate } = useSWRConfig(); + const baseUrl = siteConfig.env.backendServiceURL; + + // Helper to build cache keys (matches EntityApi pattern) + const buildListKey = useCallback((endpoint: string, params: Record) => { + const searchParams = new URLSearchParams(params).toString(); + return `${baseUrl}${endpoint}?${searchParams}`; + }, [baseUrl]); + + const buildItemKey = useCallback((endpoint: string, id: string) => { + return `${baseUrl}${endpoint}/${id}`; + }, [baseUrl]); + + // ==================== ACTION EVENTS ==================== + + useSseEventHandler( + eventSource, + 'action_created', + (data) => { + // Invalidate action list for this session + const key = buildListKey('/actions', { + coaching_session_id: data.coaching_session_id, + }); + mutate(key); + + console.log('[SSE] Revalidated actions for session:', data.coaching_session_id); + } + ); + + useSseEventHandler( + eventSource, + 'action_updated', + (data) => { + // Invalidate both the list and the specific action + const listKey = buildListKey('/actions', { + coaching_session_id: data.coaching_session_id, + }); + const itemKey = buildItemKey('/actions', data.action.id); + + mutate(listKey); + mutate(itemKey); + + console.log('[SSE] Revalidated action:', data.action.id); + } + ); + + useSseEventHandler( + eventSource, + 'action_deleted', + (data) => { + // Invalidate the list + const listKey = buildListKey('/actions', { + coaching_session_id: data.coaching_session_id, + }); + mutate(listKey); + + console.log('[SSE] Revalidated actions after deletion:', data.action_id); + } + ); + + // ==================== AGREEMENT EVENTS ==================== + + useSseEventHandler( + eventSource, + 'agreement_created', + (data) => { + const key = buildListKey('/agreements', { + coaching_relationship_id: data.coaching_relationship_id, + }); + mutate(key); + + console.log('[SSE] Revalidated agreements for relationship:', data.coaching_relationship_id); + } + ); + + useSseEventHandler( + eventSource, + 'agreement_updated', + (data) => { + const listKey = buildListKey('/agreements', { + coaching_relationship_id: data.coaching_relationship_id, + }); + const itemKey = buildItemKey('/agreements', data.agreement.id); + + mutate(listKey); + mutate(itemKey); + + console.log('[SSE] Revalidated agreement:', data.agreement.id); + } + ); + + useSseEventHandler( + eventSource, + 'agreement_deleted', + (data) => { + const listKey = buildListKey('/agreements', { + coaching_relationship_id: data.coaching_relationship_id, + }); + mutate(listKey); + + console.log('[SSE] Revalidated agreements after deletion:', data.agreement_id); + } + ); + + // ==================== GOAL EVENTS ==================== + + useSseEventHandler( + eventSource, + 'goal_created', + (data) => { + const key = buildListKey('/overarching_goals', { + coaching_relationship_id: data.coaching_relationship_id, + }); + mutate(key); + + console.log('[SSE] Revalidated goals for relationship:', data.coaching_relationship_id); + } + ); + + useSseEventHandler( + eventSource, + 'goal_updated', + (data) => { + const listKey = buildListKey('/overarching_goals', { + coaching_relationship_id: data.coaching_relationship_id, + }); + const itemKey = buildItemKey('/overarching_goals', data.goal.id); + + mutate(listKey); + mutate(itemKey); + + console.log('[SSE] Revalidated goal:', data.goal.id); + } + ); + + useSseEventHandler( + eventSource, + 'goal_deleted', + (data) => { + const listKey = buildListKey('/overarching_goals', { + coaching_relationship_id: data.coaching_relationship_id, + }); + mutate(listKey); + + console.log('[SSE] Revalidated goals after deletion:', data.goal_id); + } + ); +} +``` + +**Why:** +- **Simplest solution** - leverages existing SWR infrastructure +- **Zero component changes** - components using `useActionList` automatically get updated data +- **Consistent patterns** - same cache keys as EntityApi +- **Automatic deduplication** - SWR handles multiple simultaneous revalidations +- **Built-in error handling** - SWR retry logic applies + +--- + +### Phase 6: System Events Hook + +**File:** `src/lib/hooks/use-sse-system-events.ts` + +Handle system-level events (force logout) using existing auth infrastructure. + +```typescript +"use client"; + +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/lib/providers/auth-store-provider'; +import { useLogoutUser } from '@/lib/hooks/use-logout-user'; +import { useSseEventHandler } from './use-sse-event-handler'; +import type { ForceLogoutData } from '@/types/sse-events'; + +/** + * Handle system-level SSE events (force logout, etc.) + * Integrates with existing auth infrastructure rather than direct manipulation. + * + * @param eventSource - The SSE connection + */ +export function useSseSystemEvents(eventSource: EventSource | null) { + const router = useRouter(); + const logout = useLogoutUser(); + const isLoggedIn = useAuthStore((store) => store.isLoggedIn); + + useSseEventHandler( + eventSource, + 'force_logout', + async (data) => { + console.warn('[SSE] Force logout received:', data.reason); + + if (isLoggedIn) { + // Use existing logout infrastructure (cleans up stores, calls API, etc.) + await logout(); + + // Redirect with reason + router.push(`/login?reason=forced_logout&message=${encodeURIComponent(data.reason)}`); + } + } + ); +} +``` + +**Why:** +- Uses existing `useLogoutUser` hook (proper cleanup) +- Integrates with auth store +- Consistent with existing logout flow +- Proper navigation (not `window.location.href`) + +--- + +### Phase 7: SSE Providers + +**File:** `src/lib/providers/sse-connection-store-provider.tsx` + +Provider for SSE connection store (follows existing pattern). + +```typescript +"use client"; + +import { + type ReactNode, + createContext, + useRef, + useContext, +} from 'react'; +import { type StoreApi, useStore } from 'zustand'; +import { type SseConnectionStore, createSseConnectionStore } from '@/lib/stores/sse-connection-store'; +import { useShallow } from 'zustand/shallow'; + +export const SseConnectionStoreContext = createContext | null>(null); + +export interface SseConnectionStoreProviderProps { + children: ReactNode; +} + +export const SseConnectionStoreProvider = ({ children }: SseConnectionStoreProviderProps) => { + const storeRef = useRef>(); + + if (!storeRef.current) { + storeRef.current = createSseConnectionStore(); + } + + return ( + + {children} + + ); +}; + +export const useSseConnectionStore = ( + selector: (store: SseConnectionStore) => T +): T => { + const context = useContext(SseConnectionStoreContext); + + if (!context) { + throw new Error('useSseConnectionStore must be used within SseConnectionStoreProvider'); + } + + return useStore(context, useShallow(selector)); +}; +``` + +**File:** `src/lib/providers/sse-provider.tsx` + +Main SSE provider that composes all hooks. + +```typescript +"use client"; + +import { type ReactNode } from 'react'; +import { SseConnectionStoreProvider } from './sse-connection-store-provider'; +import { useSseConnection } from '@/lib/hooks/use-sse-connection'; +import { useSseCacheInvalidation } from '@/lib/hooks/use-sse-cache-invalidation'; +import { useSseSystemEvents } from '@/lib/hooks/use-sse-system-events'; + +interface SseClientProps { + children: ReactNode; +} + +/** + * Internal component that establishes SSE connection and sets up event handlers. + * Must be rendered inside SseConnectionStoreProvider. + */ +function SseClient({ children }: SseClientProps) { + const eventSource = useSseConnection(); + + // Auto-invalidate SWR caches on events + useSseCacheInvalidation(eventSource); + + // Handle system events (force logout) + useSseSystemEvents(eventSource); + + return <>{children}; +} + +/** + * Main SSE provider that wraps the application. + * Establishes single app-wide SSE connection and handles all events. + * + * Usage: + * ```tsx + * + * + * + * ``` + */ +export function SseProvider({ children }: SseClientProps) { + return ( + + {children} + + ); +} +``` + +**Why:** +- Follows existing provider pattern (AuthStoreProvider, OrganizationStateStoreProvider) +- Clean separation of concerns +- Composes hooks cleanly +- Single app-wide connection + +--- + +### Phase 8: Integrate into Root Providers + +**File:** `src/components/providers.tsx` (updated) + +Add SSE provider to existing provider composition. + +```typescript +"use client"; + +import { ReactNode } from 'react'; +import { AuthStoreProvider } from '@/lib/providers/auth-store-provider'; +import { OrganizationStateStoreProvider } from '@/lib/providers/organization-state-store-provider'; +import { CoachingRelationshipStateStoreProvider } from '@/lib/providers/coaching-relationship-state-store-provider'; +import { SessionCleanupProvider } from '@/lib/providers/session-cleanup-provider'; +import { SseProvider } from '@/lib/providers/sse-provider'; // NEW +import { SWRConfig } from 'swr'; + +interface ProvidersProps { + children: ReactNode; +} + +export function Providers({ children }: ProvidersProps) { + return ( + + + + + new Map(), + }} + > + {/* NEW: SSE Provider establishes connection and handles events */} + + {children} + + + + + + + ); +} +``` + +**Why:** +- Consistent with existing pattern +- SSE connection established once for entire app +- Events handled automatically +- Zero changes needed in existing components + +--- + +### Phase 9 (Optional): Connection Status UI + +**File:** `src/components/sse-connection-indicator.tsx` + +Optional visual indicator for connection status. + +```typescript +"use client"; + +import { useSseConnectionStore } from '@/lib/providers/sse-connection-store-provider'; +import { SseConnectionState } from '@/lib/stores/sse-connection-store'; +import { AlertCircle, Loader2, WifiOff } from 'lucide-react'; + +/** + * Optional UI component to display SSE connection status. + * Non-intrusive - only shows when NOT connected. + * + * Can be placed in app header/status bar: + * ```tsx + *
+ * + *
+ * ``` + */ +export function SseConnectionIndicator() { + const { state, lastError, reconnectAttempts } = useSseConnectionStore((store) => ({ + state: store.state, + lastError: store.lastError, + reconnectAttempts: store.reconnectAttempts, + })); + + // Don't show anything when connected (non-intrusive) + if (state === SseConnectionState.Connected) { + return null; + } + + const getIcon = () => { + switch (state) { + case SseConnectionState.Connecting: + case SseConnectionState.Reconnecting: + return ; + case SseConnectionState.Error: + return ; + case SseConnectionState.Disconnected: + return ; + default: + return null; + } + }; + + const getMessage = () => { + switch (state) { + case SseConnectionState.Connecting: + return 'Connecting to live updates...'; + case SseConnectionState.Reconnecting: + return `Reconnecting (attempt ${reconnectAttempts})...`; + case SseConnectionState.Error: + return lastError?.message || 'Connection error'; + case SseConnectionState.Disconnected: + return 'Disconnected from live updates'; + default: + return ''; + } + }; + + const getColorClass = () => { + switch (state) { + case SseConnectionState.Connecting: + case SseConnectionState.Reconnecting: + return 'text-yellow-600 dark:text-yellow-400'; + case SseConnectionState.Error: + return 'text-red-600 dark:text-red-400'; + case SseConnectionState.Disconnected: + return 'text-gray-600 dark:text-gray-400'; + default: + return ''; + } + }; + + return ( +
+ {getIcon()} + {getMessage()} +
+ ); +} +``` + +**Usage:** +```tsx +// In app header or status bar +import { SseConnectionIndicator } from '@/components/sse-connection-indicator'; + +export function AppHeader() { + return ( +
+ + {/* other header content */} +
+ ); +} +``` + +**Why:** +- Non-intrusive (hidden when connected) +- Visual feedback for users +- Useful for debugging +- Optional (not required for functionality) + +--- + +## Testing Strategy + +### Unit Tests + +**Test SSE Event Handler:** +```typescript +// src/lib/hooks/__tests__/use-sse-event-handler.test.ts +import { renderHook } from '@testing-library/react'; +import { useSseEventHandler } from '../use-sse-event-handler'; +import type { ActionCreatedData } from '@/types/sse-events'; + +describe('useSseEventHandler', () => { + it('should handle action_created events with correct typing', () => { + const mockEventSource = new EventSource('mock-url'); + const mockHandler = jest.fn(); + + renderHook(() => + useSseEventHandler( + mockEventSource, + 'action_created', + mockHandler + ) + ); + + // Trigger event + const event = new MessageEvent('action_created', { + data: JSON.stringify({ + type: 'action_created', + data: { + coaching_session_id: 'session-123', + action: { id: 'action-1', /* ... */ } + } + }) + }); + + mockEventSource.dispatchEvent(event); + + // Verify handler called with typed data + expect(mockHandler).toHaveBeenCalledWith( + expect.objectContaining({ + coaching_session_id: 'session-123', + action: expect.objectContaining({ id: 'action-1' }) + }) + ); + }); +}); +``` + +**Test SWR Cache Invalidation:** +```typescript +// src/lib/hooks/__tests__/use-sse-cache-invalidation.test.ts +import { renderHook, waitFor } from '@testing-library/react'; +import { useSseCacheInvalidation } from '../use-sse-cache-invalidation'; +import { useActionList } from '@/lib/api/actions'; +import { mutate } from 'swr'; + +jest.mock('swr', () => ({ + useSWRConfig: () => ({ mutate: jest.fn() }), +})); + +describe('useSseCacheInvalidation', () => { + it('should revalidate action cache on action_created event', async () => { + const mockEventSource = new EventSource('mock-url'); + const mockMutate = jest.fn(); + + (mutate as jest.Mock).mockImplementation(mockMutate); + + renderHook(() => useSseCacheInvalidation(mockEventSource)); + + // Trigger SSE event + const event = new MessageEvent('action_created', { + data: JSON.stringify({ + type: 'action_created', + data: { + coaching_session_id: 'session-123', + action: { id: 'action-1' } + } + }) + }); + + mockEventSource.dispatchEvent(event); + + // Verify SWR cache was invalidated + await waitFor(() => { + expect(mockMutate).toHaveBeenCalledWith( + expect.stringContaining('/actions?coaching_session_id=session-123') + ); + }); + }); +}); +``` + +### Integration Tests + +**Test End-to-End Flow:** +```typescript +// Integration test: SSE event → cache invalidation → component update +import { render, screen, waitFor } from '@testing-library/react'; +import { SseProvider } from '@/lib/providers/sse-provider'; +import { CoachingSessionPage } from '@/app/coaching-sessions/[id]/page'; + +describe('SSE Integration', () => { + it('should update action list when action_created event received', async () => { + // Render component with SSE provider + render( + + + + ); + + // Initial state: no actions + expect(screen.queryByText('Test Action')).not.toBeInTheDocument(); + + // Simulate SSE event from backend + const event = new MessageEvent('action_created', { + data: JSON.stringify({ + type: 'action_created', + data: { + coaching_session_id: 'session-123', + action: { id: 'action-1', body: 'Test Action' } + } + }) + }); + + window.EventSource.prototype.dispatchEvent(event); + + // Wait for SWR to refetch and component to update + await waitFor(() => { + expect(screen.getByText('Test Action')).toBeInTheDocument(); + }); + }); +}); +``` + +--- + +## Security Considerations + +### Authentication +- SSE endpoint requires valid session cookie (handled by `withCredentials: true`) +- Backend validates session via `AuthenticatedUser` extractor +- No additional auth tokens needed + +### Authorization +- Backend determines event recipients (not client-controlled) +- Events only sent to authorized users +- Frontend cannot request events for other users + +### Data Validation +- Type guards validate event structure at runtime +- Prevents malformed events from crashing UI +- Console errors logged for debugging + +### Connection Security +- HTTPS enforced in production (via nginx) +- Session cookies are httpOnly and secure +- No sensitive data in event stream (just IDs and references) + +--- + +## Performance Considerations + +### Connection Overhead +- **Single connection per user** (not per component) +- Minimal bandwidth (only events for this user) +- Keep-alive messages every 15s (small overhead) + +### Memory Usage +- EventSource object: ~1KB per connection +- Event handlers: minimal (closures with refs) +- Zustand store: ~1KB for connection state + +### Network Usage +- Events only sent when changes occur +- No polling (reduces bandwidth vs. polling solutions) +- Automatic reconnection prevents connection leaks + +### SWR Cache Behavior +- Multiple simultaneous invalidations are deduplicated by SWR +- SWR batches requests within 2 seconds +- Existing SWR cache stays fresh without polling + +--- + +## Debugging Guide + +### Connection Issues + +**Check connection state:** +```typescript +// In any component +import { useSseConnectionStore } from '@/lib/providers/sse-connection-store-provider'; + +function DebugComponent() { + const connectionState = useSseConnectionStore((store) => ({ + state: store.state, + lastError: store.lastError, + reconnectAttempts: store.reconnectAttempts, + lastConnectedAt: store.lastConnectedAt, + lastEventAt: store.lastEventAt, + })); + + return
{JSON.stringify(connectionState, null, 2)}
; +} +``` + +**Check browser DevTools:** +1. Open Redux DevTools → `sse-connection-store` +2. View connection state, errors, timestamps +3. Monitor state transitions + +**Check browser console:** +``` +[SSE] Connection established +[SSE] Revalidated actions for session: abc-123 +[SSE] Connection error: Failed to fetch +[SSE] Reconnecting in 1500ms... +``` + +### Event Flow Issues + +**Verify events are received:** +```typescript +// Add debug handler +useSseEventHandler( + eventSource, + 'action_created', + (data) => { + console.log('[DEBUG] Received action_created:', data); + } +); +``` + +**Check SWR cache keys:** +```typescript +// In browser console +window.swr = useSWRConfig(); +console.log(window.swr.cache.keys()); +``` + +**Verify cache invalidation:** +```typescript +// Watch for mutate calls +const originalMutate = mutate; +mutate = (...args) => { + console.log('[DEBUG] SWR mutate called:', args[0]); + return originalMutate(...args); +}; +``` + +--- + +## Migration from Polling (If Applicable) + +If currently using polling, migration is straightforward: + +**Before (Polling):** +```typescript +// Remove polling interval +useEffect(() => { + const interval = setInterval(() => { + refresh(); // Manual refresh every 5 seconds + }, 5000); + return () => clearInterval(interval); +}, [refresh]); +``` + +**After (SSE):** +```typescript +// No changes needed in component! +// SSE automatically triggers cache invalidation +// Components using useActionList automatically get updates +``` + +**Benefits:** +- Reduced server load (no constant polling) +- Lower latency (events arrive immediately) +- Less bandwidth usage +- Better battery life (mobile devices) + +--- + +## Future Enhancements (Out of Scope) + +### Advanced Features +- **Optimistic updates**: Update UI before event arrives, rollback on error +- **Event replay**: Persist critical events for offline users +- **Presence indicators**: Show who's viewing a session +- **Typing indicators**: Real-time collaboration features +- **Conflict resolution**: Handle simultaneous edits + +### Additional Event Types +- Session lifecycle: `session_started`, `session_ended` +- User presence: `user_joined_session`, `user_left_session` +- Notifications: `notification_created`, `notification_dismissed` + +### Performance Optimizations +- **Event batching**: Group multiple events into single message +- **Compression**: Enable gzip for event stream +- **Selective subscriptions**: Subscribe only to relevant event types + +--- + +## References + +### Documentation +- [MDN Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) +- [SWR Documentation](https://swr.vercel.app/) +- [Zustand Documentation](https://docs.pmnd.rs/zustand) +- [Next.js Client Components](https://nextjs.org/docs/app/building-your-application/rendering/client-components) + +### Backend Integration +- See `docs/implementation-plans/sse-communication.md` in backend repo for: + - SSE event format + - Authentication flow + - Nginx configuration + - Message routing logic + +--- + +## Summary + +This implementation provides a robust, type-safe, and maintainable SSE solution that: + +✅ **Follows existing patterns** (SWR, Zustand, hooks, providers) +✅ **Requires zero component changes** (automatic cache invalidation) +✅ **Strongly typed throughout** (no `any` types) +✅ **Handles failures gracefully** (automatic reconnection with backoff) +✅ **Integrates seamlessly** (with existing auth, routing, caching) +✅ **Production-ready** (error tracking, debugging, monitoring) + +The key insight: **leverage SWR cache invalidation instead of manual state updates**. This is simpler, more reliable, and consistent with existing codebase patterns. From 3a5a8d4044f2dda21f09706e7f6c243cbc6a8962 Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Tue, 9 Dec 2025 05:11:02 -0500 Subject: [PATCH 02/19] refine sse plan --- .../implementation-plans/sse-communication.md | 131 +++++++----------- 1 file changed, 52 insertions(+), 79 deletions(-) diff --git a/docs/implementation-plans/sse-communication.md b/docs/implementation-plans/sse-communication.md index 0e01e5e5..7a6a8ab5 100644 --- a/docs/implementation-plans/sse-communication.md +++ b/docs/implementation-plans/sse-communication.md @@ -322,11 +322,11 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; export enum SseConnectionState { - Disconnected = 'disconnected', - Connecting = 'connecting', - Connected = 'connected', - Reconnecting = 'reconnecting', - Error = 'error', + Disconnected = "Disconnected", + Connecting = "Connecting", + Connected = "Connected", + Reconnecting = "Reconnecting", + Error = "Error", } interface SseError { @@ -458,21 +458,22 @@ const DEFAULT_RECONNECTION_CONFIG: ReconnectionConfig = { * Core SSE connection hook with automatic reconnection and exponential backoff. * * Features: + * - Auth-gated connection (only connects when user is authenticated) * - Automatic reconnection with exponential backoff * - Connection state management via Zustand store - * - Graceful cleanup on unmount + * - Graceful cleanup on unmount and logout * - Event listener management * + * @param isLoggedIn - Whether the user is authenticated (gates connection establishment) * @returns EventSource instance or null if not connected */ -export function useSseConnection() { +export function useSseConnection(isLoggedIn: boolean) { const eventSourceRef = useRef(null); const reconnectTimeoutRef = useRef(null); + const reconnectAttemptsRef = useRef(0); const mountedRef = useRef(true); const { - state, - reconnectAttempts, setConnecting, setConnected, setReconnecting, @@ -480,8 +481,6 @@ export function useSseConnection() { setDisconnected, resetReconnectAttempts, } = useSseConnectionStore((store) => ({ - state: store.state, - reconnectAttempts: store.reconnectAttempts, setConnecting: store.setConnecting, setConnected: store.setConnected, setReconnecting: store.setReconnecting, @@ -511,7 +510,7 @@ export function useSseConnection() { }, []); const connect = useCallback(() => { - if (!mountedRef.current) return; + if (!mountedRef.current || !isLoggedIn) return; // Close existing connection cleanup(); @@ -532,6 +531,7 @@ export function useSseConnection() { console.log('[SSE] Connection established'); setConnected(); resetReconnectAttempts(); + reconnectAttemptsRef.current = 0; }; es.onerror = (error) => { @@ -541,7 +541,8 @@ export function useSseConnection() { // EventSource automatically attempts to reconnect on error // We handle the error state and implement our own backoff - const currentAttempts = reconnectAttempts + 1; + reconnectAttemptsRef.current += 1; + const currentAttempts = reconnectAttemptsRef.current; if (currentAttempts >= DEFAULT_RECONNECTION_CONFIG.maxAttempts) { setError(`Max reconnection attempts (${DEFAULT_RECONNECTION_CONFIG.maxAttempts}) reached`); @@ -554,7 +555,7 @@ export function useSseConnection() { console.log(`[SSE] Reconnecting in ${delay}ms...`); reconnectTimeoutRef.current = setTimeout(() => { - if (mountedRef.current) { + if (mountedRef.current && isLoggedIn) { connect(); } }, delay); @@ -573,14 +574,20 @@ export function useSseConnection() { setError, setReconnecting, resetReconnectAttempts, - reconnectAttempts, calculateBackoff, + isLoggedIn, ]); - // Establish connection on mount + // Establish connection when logged in, disconnect when logged out useEffect(() => { mountedRef.current = true; - connect(); + + if (isLoggedIn) { + connect(); + } else { + cleanup(); + setDisconnected(); + } // Cleanup on unmount return () => { @@ -588,17 +595,20 @@ export function useSseConnection() { cleanup(); setDisconnected(); }; - }, [connect, cleanup, setDisconnected]); + }, [isLoggedIn, connect, cleanup, setDisconnected]); return eventSourceRef.current; } ``` **Why:** +- Auth-gated connection ensures SSE only connects for authenticated users +- Uses ref for reconnectAttempts to avoid triggering useCallback re-creation - Automatic reconnection prevents permanent disconnection - Exponential backoff prevents server overload - Cleanup prevents memory leaks - Connection state visible in DevTools +- Responds to logout by immediately closing connection --- @@ -736,27 +746,14 @@ export function useSseCacheInvalidation(eventSource: EventSource | null) { const { mutate } = useSWRConfig(); const baseUrl = siteConfig.env.backendServiceURL; - // Helper to build cache keys (matches EntityApi pattern) - const buildListKey = useCallback((endpoint: string, params: Record) => { - const searchParams = new URLSearchParams(params).toString(); - return `${baseUrl}${endpoint}?${searchParams}`; - }, [baseUrl]); - - const buildItemKey = useCallback((endpoint: string, id: string) => { - return `${baseUrl}${endpoint}/${id}`; - }, [baseUrl]); - // ==================== ACTION EVENTS ==================== useSseEventHandler( eventSource, 'action_created', (data) => { - // Invalidate action list for this session - const key = buildListKey('/actions', { - coaching_session_id: data.coaching_session_id, - }); - mutate(key); + // Invalidate all action caches + mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/actions`)); console.log('[SSE] Revalidated actions for session:', data.coaching_session_id); } @@ -766,14 +763,8 @@ export function useSseCacheInvalidation(eventSource: EventSource | null) { eventSource, 'action_updated', (data) => { - // Invalidate both the list and the specific action - const listKey = buildListKey('/actions', { - coaching_session_id: data.coaching_session_id, - }); - const itemKey = buildItemKey('/actions', data.action.id); - - mutate(listKey); - mutate(itemKey); + // Invalidate all action caches + mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/actions`)); console.log('[SSE] Revalidated action:', data.action.id); } @@ -783,11 +774,8 @@ export function useSseCacheInvalidation(eventSource: EventSource | null) { eventSource, 'action_deleted', (data) => { - // Invalidate the list - const listKey = buildListKey('/actions', { - coaching_session_id: data.coaching_session_id, - }); - mutate(listKey); + // Invalidate all action caches + mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/actions`)); console.log('[SSE] Revalidated actions after deletion:', data.action_id); } @@ -799,10 +787,8 @@ export function useSseCacheInvalidation(eventSource: EventSource | null) { eventSource, 'agreement_created', (data) => { - const key = buildListKey('/agreements', { - coaching_relationship_id: data.coaching_relationship_id, - }); - mutate(key); + // Invalidate all agreement caches + mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/agreements`)); console.log('[SSE] Revalidated agreements for relationship:', data.coaching_relationship_id); } @@ -812,13 +798,8 @@ export function useSseCacheInvalidation(eventSource: EventSource | null) { eventSource, 'agreement_updated', (data) => { - const listKey = buildListKey('/agreements', { - coaching_relationship_id: data.coaching_relationship_id, - }); - const itemKey = buildItemKey('/agreements', data.agreement.id); - - mutate(listKey); - mutate(itemKey); + // Invalidate all agreement caches + mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/agreements`)); console.log('[SSE] Revalidated agreement:', data.agreement.id); } @@ -828,10 +809,8 @@ export function useSseCacheInvalidation(eventSource: EventSource | null) { eventSource, 'agreement_deleted', (data) => { - const listKey = buildListKey('/agreements', { - coaching_relationship_id: data.coaching_relationship_id, - }); - mutate(listKey); + // Invalidate all agreement caches + mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/agreements`)); console.log('[SSE] Revalidated agreements after deletion:', data.agreement_id); } @@ -843,10 +822,8 @@ export function useSseCacheInvalidation(eventSource: EventSource | null) { eventSource, 'goal_created', (data) => { - const key = buildListKey('/overarching_goals', { - coaching_relationship_id: data.coaching_relationship_id, - }); - mutate(key); + // Invalidate all goal caches + mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/overarching_goals`)); console.log('[SSE] Revalidated goals for relationship:', data.coaching_relationship_id); } @@ -856,13 +833,8 @@ export function useSseCacheInvalidation(eventSource: EventSource | null) { eventSource, 'goal_updated', (data) => { - const listKey = buildListKey('/overarching_goals', { - coaching_relationship_id: data.coaching_relationship_id, - }); - const itemKey = buildItemKey('/overarching_goals', data.goal.id); - - mutate(listKey); - mutate(itemKey); + // Invalidate all goal caches + mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/overarching_goals`)); console.log('[SSE] Revalidated goal:', data.goal.id); } @@ -872,10 +844,8 @@ export function useSseCacheInvalidation(eventSource: EventSource | null) { eventSource, 'goal_deleted', (data) => { - const listKey = buildListKey('/overarching_goals', { - coaching_relationship_id: data.coaching_relationship_id, - }); - mutate(listKey); + // Invalidate all goal caches + mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/overarching_goals`)); console.log('[SSE] Revalidated goals after deletion:', data.goal_id); } @@ -886,7 +856,8 @@ export function useSseCacheInvalidation(eventSource: EventSource | null) { **Why:** - **Simplest solution** - leverages existing SWR infrastructure - **Zero component changes** - components using `useActionList` automatically get updated data -- **Consistent patterns** - same cache keys as EntityApi +- **Consistent patterns** - matches `EntityApi.useEntityMutation` cache invalidation pattern +- **Broad invalidation** - simple `includes()` check invalidates all related caches safely - **Automatic deduplication** - SWR handles multiple simultaneous revalidations - **Built-in error handling** - SWR retry logic applies @@ -1005,6 +976,7 @@ Main SSE provider that composes all hooks. import { type ReactNode } from 'react'; import { SseConnectionStoreProvider } from './sse-connection-store-provider'; +import { useAuthStore } from '@/lib/providers/auth-store-provider'; import { useSseConnection } from '@/lib/hooks/use-sse-connection'; import { useSseCacheInvalidation } from '@/lib/hooks/use-sse-cache-invalidation'; import { useSseSystemEvents } from '@/lib/hooks/use-sse-system-events'; @@ -1015,10 +987,11 @@ interface SseClientProps { /** * Internal component that establishes SSE connection and sets up event handlers. - * Must be rendered inside SseConnectionStoreProvider. + * Must be rendered inside SseConnectionStoreProvider and AuthStoreProvider. */ function SseClient({ children }: SseClientProps) { - const eventSource = useSseConnection(); + const isLoggedIn = useAuthStore((store) => store.isLoggedIn); + const eventSource = useSseConnection(isLoggedIn); // Auto-invalidate SWR caches on events useSseCacheInvalidation(eventSource); From 1281f4bcbfac87f1c7d6ef6373092bfd60a1934d Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Tue, 9 Dec 2025 06:23:19 -0500 Subject: [PATCH 03/19] more sse plan updates --- .../implementation-plans/sse-communication.md | 1122 ++++------------- 1 file changed, 263 insertions(+), 859 deletions(-) diff --git a/docs/implementation-plans/sse-communication.md b/docs/implementation-plans/sse-communication.md index 7a6a8ab5..a1f63660 100644 --- a/docs/implementation-plans/sse-communication.md +++ b/docs/implementation-plans/sse-communication.md @@ -73,7 +73,7 @@ sequenceDiagram Backend->>SSE: SSE Event: action_created Note over SSE,Cache: Automatic Cache Invalidation - SSE->>Cache: mutate("/actions?session_id=...") + SSE->>Cache: mutate(baseUrl) Cache->>Backend: Refetch GET /actions Backend-->>Cache: Updated action list Cache-->>User2: Updated data via useActionList @@ -96,25 +96,31 @@ sequenceDiagram - Built-in error handling and retry logic - Works with existing optimistic updates -### 2. Strong Typing Throughout (No `any` Types) +### 2. Strong Typing Throughout **Why:** TypeScript provides compile-time safety and IntelliSense support. **Implementation:** - Event types match backend exactly (`src/types/sse-events.ts`) -- Type guards for runtime validation -- Generic type parameters for event handlers +- Discriminated unions for automatic type narrowing +- No custom type helpers needed (uses standard TypeScript patterns) - Strict null checks -### 3. Automatic Reconnection with Exponential Backoff +### 3. Use Native EventSource API -**Why:** Network failures are inevitable in production. +**Why:** Browser native API provides all needed functionality without external dependencies. -**Implementation:** -- Exponential backoff (1s → 1.5s → 2.25s → ... → 30s max) -- Max 10 reconnection attempts -- Connection state tracked in Zustand store -- Visible in DevTools for debugging +**Native EventSource provides:** +- Automatic reconnection (~3 seconds default) +- Built-in Last-Event-ID support for resumable streams +- CORS credentials mode via `withCredentials` option +- Standard EventTarget interface for event handling +- No external dependencies to maintain + +**Alignment with codebase:** +- Uses native browser APIs (consistent with existing patterns) +- Backend supports GET with cookie authentication (EventSource standard) +- Simpler, more maintainable solution following web standards ### 4. Connection State Management via Zustand @@ -126,15 +132,26 @@ sequenceDiagram - Error tracking with timestamps and attempt numbers - DevTools integration -### 5. Provider Composition Pattern +### 5. Single Provider Pattern -**Why:** Consistent with existing provider architecture. +**Why:** Follows existing `AuthStoreProvider` pattern exactly. **Implementation:** -- `SseConnectionStoreProvider` - Provides connection state -- `SseProvider` - Composes hooks and provides to app +- Single `SseProvider` creates store AND establishes connection +- Store creation happens inside provider (like `AuthStoreProvider`) +- Hooks composed within provider component - Added to root `Providers` component +### 6. Logout Cleanup Registry Integration + +**Why:** Follows existing cleanup pattern for proper connection teardown. + +**Implementation:** +- Registers SSE cleanup with `logoutCleanupRegistry` +- Ensures connection closed during logout flow +- Consistent with TipTap and other provider cleanup +- Prevents connection leaks and security issues + --- ## File Structure @@ -142,36 +159,34 @@ sequenceDiagram ``` src/ ├── types/ -│ └── sse-events.ts # Event type definitions +│ └── sse-events.ts # Event type definitions │ ├── lib/ │ ├── stores/ -│ │ └── sse-connection-store.ts # Connection state management +│ │ └── sse-connection-store.ts # Connection state management │ │ │ ├── hooks/ -│ │ ├── use-sse-connection.ts # SSE connection with reconnection -│ │ ├── use-sse-event-handler.ts # Type-safe event handler -│ │ ├── use-sse-cache-invalidation.ts # SWR cache invalidation -│ │ └── use-sse-system-events.ts # System events (force logout) +│ │ ├── use-sse-connection.ts # Native EventSource connection +│ │ ├── use-sse-event-handler.ts # Type-safe event handler +│ │ ├── use-sse-cache-invalidation.ts # SWR cache invalidation +│ │ └── use-sse-system-events.ts # System events (force logout) │ │ │ └── providers/ -│ ├── sse-connection-store-provider.tsx # Connection store provider -│ └── sse-provider.tsx # Main SSE provider +│ └── sse-provider.tsx # SSE provider (store + connection) │ └── components/ - ├── providers.tsx # Root providers (updated) - └── sse-connection-indicator.tsx # Optional: UI indicator + └── providers.tsx # Root providers (updated) ``` --- ## Implementation Phases -### Phase 1: Type Definitions +### Phase 1: Type Definitions (Discriminated Unions) **File:** `src/types/sse-events.ts` -Create strongly-typed event definitions matching backend exactly. +Create strongly-typed event definitions using discriminated unions that match the backend's Rust serialization format exactly (`#[serde(tag = "type", content = "data")]`). ```typescript import { Id } from './general'; @@ -180,143 +195,123 @@ import { Agreement } from './agreement'; import { OverarchingGoal } from './overarching-goal'; /** - * SSE event types - must match backend exactly - */ -export type SseEventType = - | 'action_created' - | 'action_updated' - | 'action_deleted' - | 'agreement_created' - | 'agreement_updated' - | 'agreement_deleted' - | 'goal_created' - | 'goal_updated' - | 'goal_deleted' - | 'force_logout'; - -/** - * SSE event payload wrapper - matches backend serialization - * Backend sends: { type: "action_created", data: { coaching_session_id, action } } + * Base SSE event structure matching backend serialization */ -export interface SseEventEnvelope { - type: SseEventType; - data: T; +interface BaseSseEvent { + type: T; + data: D; } -// Session-scoped events -export interface ActionCreatedData { - coaching_session_id: Id; - action: Action; -} - -export interface ActionUpdatedData { - coaching_session_id: Id; - action: Action; -} +// ==================== ACTION EVENTS (session-scoped) ==================== -export interface ActionDeletedData { - coaching_session_id: Id; - action_id: Id; -} +export type ActionCreatedEvent = BaseSseEvent< + 'action_created', + { + coaching_session_id: Id; + action: Action; + } +>; -// Relationship-scoped events -export interface AgreementCreatedData { - coaching_relationship_id: Id; - agreement: Agreement; -} +export type ActionUpdatedEvent = BaseSseEvent< + 'action_updated', + { + coaching_session_id: Id; + action: Action; + } +>; -export interface AgreementUpdatedData { - coaching_relationship_id: Id; - agreement: Agreement; -} +export type ActionDeletedEvent = BaseSseEvent< + 'action_deleted', + { + coaching_session_id: Id; + action_id: Id; + } +>; -export interface AgreementDeletedData { - coaching_relationship_id: Id; - agreement_id: Id; -} +// ==================== AGREEMENT EVENTS (relationship-scoped) ==================== -export interface GoalCreatedData { - coaching_relationship_id: Id; - goal: OverarchingGoal; -} +export type AgreementCreatedEvent = BaseSseEvent< + 'agreement_created', + { + coaching_relationship_id: Id; + agreement: Agreement; + } +>; -export interface GoalUpdatedData { - coaching_relationship_id: Id; - goal: OverarchingGoal; -} +export type AgreementUpdatedEvent = BaseSseEvent< + 'agreement_updated', + { + coaching_relationship_id: Id; + agreement: Agreement; + } +>; -export interface GoalDeletedData { - coaching_relationship_id: Id; - goal_id: Id; -} +export type AgreementDeletedEvent = BaseSseEvent< + 'agreement_deleted', + { + coaching_relationship_id: Id; + agreement_id: Id; + } +>; -// System events -export interface ForceLogoutData { - reason: string; -} +// ==================== GOAL EVENTS (relationship-scoped) ==================== -// Union type for all event data -export type SseEventData = - | ActionCreatedData - | ActionUpdatedData - | ActionDeletedData - | AgreementCreatedData - | AgreementUpdatedData - | AgreementDeletedData - | GoalCreatedData - | GoalUpdatedData - | GoalDeletedData - | ForceLogoutData; +export type GoalCreatedEvent = BaseSseEvent< + 'goal_created', + { + coaching_relationship_id: Id; + goal: OverarchingGoal; + } +>; -/** - * Type guards for runtime validation - */ -export function isActionCreatedData(data: unknown): data is ActionCreatedData { - if (!data || typeof data !== 'object') return false; - const obj = data as Record; - return ( - typeof obj.coaching_session_id === 'string' && - typeof obj.action === 'object' && - obj.action !== null - ); -} +export type GoalUpdatedEvent = BaseSseEvent< + 'goal_updated', + { + coaching_relationship_id: Id; + goal: OverarchingGoal; + } +>; -export function isActionUpdatedData(data: unknown): data is ActionUpdatedData { - return isActionCreatedData(data); -} +export type GoalDeletedEvent = BaseSseEvent< + 'goal_deleted', + { + coaching_relationship_id: Id; + goal_id: Id; + } +>; -export function isActionDeletedData(data: unknown): data is ActionDeletedData { - if (!data || typeof data !== 'object') return false; - const obj = data as Record; - return ( - typeof obj.coaching_session_id === 'string' && - typeof obj.action_id === 'string' - ); -} +// ==================== SYSTEM EVENTS ==================== -export function isForceLogoutData(data: unknown): data is ForceLogoutData { - if (!data || typeof data !== 'object') return false; - const obj = data as Record; - return typeof obj.reason === 'string'; -} +export type ForceLogoutEvent = BaseSseEvent< + 'force_logout', + { + reason: string; + } +>; -// TODO: Add type guards for agreements, goals, etc. +/** + * Discriminated union of all SSE events + * TypeScript automatically narrows the type based on the 'type' property + */ +export type SseEvent = + | ActionCreatedEvent + | ActionUpdatedEvent + | ActionDeletedEvent + | AgreementCreatedEvent + | AgreementUpdatedEvent + | AgreementDeletedEvent + | GoalCreatedEvent + | GoalUpdatedEvent + | GoalDeletedEvent + | ForceLogoutEvent; ``` -**Why:** -- Compile-time type safety -- IntelliSense support in event handlers -- Runtime validation via type guards -- Single source of truth for event structure - --- ### Phase 2: Connection State Store **File:** `src/lib/stores/sse-connection-store.ts` -Create Zustand store for connection state management. - ```typescript import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; @@ -325,7 +320,6 @@ export enum SseConnectionState { Disconnected = "Disconnected", Connecting = "Connecting", Connected = "Connected", - Reconnecting = "Reconnecting", Error = "Error", } @@ -338,7 +332,6 @@ interface SseError { interface SseConnectionStateData { state: SseConnectionState; lastError: SseError | null; - reconnectAttempts: number; lastConnectedAt: Date | null; lastEventAt: Date | null; } @@ -346,11 +339,9 @@ interface SseConnectionStateData { interface SseConnectionActions { setConnecting: () => void; setConnected: () => void; - setReconnecting: (attempt: number) => void; setError: (error: string) => void; setDisconnected: () => void; recordEvent: () => void; - resetReconnectAttempts: () => void; } export type SseConnectionStore = SseConnectionStateData & SseConnectionActions; @@ -358,7 +349,6 @@ export type SseConnectionStore = SseConnectionStateData & SseConnectionActions; const defaultState: SseConnectionStateData = { state: SseConnectionState.Disconnected, lastError: null, - reconnectAttempts: 0, lastConnectedAt: null, lastEventAt: null, }; @@ -377,27 +367,19 @@ export const createSseConnectionStore = () => { set({ state: SseConnectionState.Connected, lastConnectedAt: new Date(), - reconnectAttempts: 0, lastError: null, }); }, - setReconnecting: (attempt: number) => { - set({ - state: SseConnectionState.Reconnecting, - reconnectAttempts: attempt, - }); - }, - setError: (message: string) => { - set((state) => ({ + set({ state: SseConnectionState.Error, lastError: { message, timestamp: new Date(), - attemptNumber: state.reconnectAttempts, + attemptNumber: 0, }, - })); + }); }, setDisconnected: () => { @@ -407,10 +389,6 @@ export const createSseConnectionStore = () => { recordEvent: () => { set({ lastEventAt: new Date() }); }, - - resetReconnectAttempts: () => { - set({ reconnectAttempts: 0 }); - }, }), { name: 'sse-connection-store' } ) @@ -418,241 +396,101 @@ export const createSseConnectionStore = () => { }; ``` -**Why:** -- Follows existing Zustand store pattern -- DevTools integration for debugging -- Tracks connection health and errors -- Visible in Redux DevTools - --- -### Phase 3: SSE Connection Hook +### Phase 3: SSE Connection Hook (Native EventSource) **File:** `src/lib/hooks/use-sse-connection.ts` -Core hook that establishes SSE connection with automatic reconnection. - ```typescript "use client"; -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef } from 'react'; import { siteConfig } from '@/site.config'; -import { useSseConnectionStore } from '@/lib/providers/sse-connection-store-provider'; -import { SseConnectionState } from '@/lib/stores/sse-connection-store'; - -interface ReconnectionConfig { - maxAttempts: number; - initialDelay: number; - maxDelay: number; - backoffMultiplier: number; -} - -const DEFAULT_RECONNECTION_CONFIG: ReconnectionConfig = { - maxAttempts: 10, - initialDelay: 1000, // 1 second - maxDelay: 30000, // 30 seconds - backoffMultiplier: 1.5, -}; +import { useSseConnectionStore } from '@/lib/providers/sse-provider'; +import { logoutCleanupRegistry } from '@/lib/hooks/logout-cleanup-registry'; -/** - * Core SSE connection hook with automatic reconnection and exponential backoff. - * - * Features: - * - Auth-gated connection (only connects when user is authenticated) - * - Automatic reconnection with exponential backoff - * - Connection state management via Zustand store - * - Graceful cleanup on unmount and logout - * - Event listener management - * - * @param isLoggedIn - Whether the user is authenticated (gates connection establishment) - * @returns EventSource instance or null if not connected - */ export function useSseConnection(isLoggedIn: boolean) { const eventSourceRef = useRef(null); - const reconnectTimeoutRef = useRef(null); - const reconnectAttemptsRef = useRef(0); - const mountedRef = useRef(true); const { setConnecting, setConnected, - setReconnecting, setError, setDisconnected, - resetReconnectAttempts, + recordEvent, } = useSseConnectionStore((store) => ({ setConnecting: store.setConnecting, setConnected: store.setConnected, - setReconnecting: store.setReconnecting, setError: store.setError, setDisconnected: store.setDisconnected, - resetReconnectAttempts: store.resetReconnectAttempts, + recordEvent: store.recordEvent, })); - const calculateBackoff = useCallback((attempt: number): number => { - const delay = Math.min( - DEFAULT_RECONNECTION_CONFIG.initialDelay * - Math.pow(DEFAULT_RECONNECTION_CONFIG.backoffMultiplier, attempt), - DEFAULT_RECONNECTION_CONFIG.maxDelay - ); - return delay; - }, []); - - const cleanup = useCallback(() => { - if (eventSourceRef.current) { - eventSourceRef.current.close(); + useEffect(() => { + if (!isLoggedIn) { + eventSourceRef.current?.close(); eventSourceRef.current = null; + setDisconnected(); + return; } - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - }, []); - - const connect = useCallback(() => { - if (!mountedRef.current || !isLoggedIn) return; - - // Close existing connection - cleanup(); setConnecting(); - try { - const es = new EventSource( - `${siteConfig.env.backendServiceURL}/sse`, - { withCredentials: true } - ); + const source = new EventSource(`${siteConfig.env.backendServiceURL}/sse`, { + withCredentials: true, + }); - es.onopen = () => { - if (!mountedRef.current) { - es.close(); - return; - } - console.log('[SSE] Connection established'); - setConnected(); - resetReconnectAttempts(); - reconnectAttemptsRef.current = 0; - }; - - es.onerror = (error) => { - if (!mountedRef.current) return; - - console.error('[SSE] Connection error:', error); - - // EventSource automatically attempts to reconnect on error - // We handle the error state and implement our own backoff - reconnectAttemptsRef.current += 1; - const currentAttempts = reconnectAttemptsRef.current; - - if (currentAttempts >= DEFAULT_RECONNECTION_CONFIG.maxAttempts) { - setError(`Max reconnection attempts (${DEFAULT_RECONNECTION_CONFIG.maxAttempts}) reached`); - cleanup(); - } else { - setError(`Connection error (attempt ${currentAttempts})`); - setReconnecting(currentAttempts); - - const delay = calculateBackoff(currentAttempts); - console.log(`[SSE] Reconnecting in ${delay}ms...`); - - reconnectTimeoutRef.current = setTimeout(() => { - if (mountedRef.current && isLoggedIn) { - connect(); - } - }, delay); - } - }; + source.onopen = () => { + console.log('[SSE] Connection established'); + setConnected(); + }; - eventSourceRef.current = es; - } catch (error) { - console.error('[SSE] Failed to create EventSource:', error); - setError(error instanceof Error ? error.message : 'Unknown error'); - } - }, [ - cleanup, - setConnecting, - setConnected, - setError, - setReconnecting, - resetReconnectAttempts, - calculateBackoff, - isLoggedIn, - ]); + source.onerror = (error) => { + console.error('[SSE] Connection error:', error); + setError('Connection error - browser will auto-reconnect'); + }; - // Establish connection when logged in, disconnect when logged out - useEffect(() => { - mountedRef.current = true; + eventSourceRef.current = source; - if (isLoggedIn) { - connect(); - } else { - cleanup(); - setDisconnected(); - } + const unregisterCleanup = logoutCleanupRegistry.register(() => { + console.log('[SSE] Cleaning up connection on logout'); + source.close(); + }); - // Cleanup on unmount return () => { - mountedRef.current = false; - cleanup(); + source.close(); setDisconnected(); + unregisterCleanup(); }; - }, [isLoggedIn, connect, cleanup, setDisconnected]); + }, [isLoggedIn, setConnecting, setConnected, setError, setDisconnected]); return eventSourceRef.current; } ``` -**Why:** -- Auth-gated connection ensures SSE only connects for authenticated users -- Uses ref for reconnectAttempts to avoid triggering useCallback re-creation -- Automatic reconnection prevents permanent disconnection -- Exponential backoff prevents server overload -- Cleanup prevents memory leaks -- Connection state visible in DevTools -- Responds to logout by immediately closing connection - --- ### Phase 4: Event Handler Hook **File:** `src/lib/hooks/use-sse-event-handler.ts` -Type-safe hook for handling SSE events. - ```typescript "use client"; import { useEffect, useRef } from 'react'; -import type { SseEventType, SseEventData } from '@/types/sse-events'; -import { useSseConnectionStore } from '@/lib/providers/sse-connection-store-provider'; +import type { SseEvent } from '@/types/sse-events'; +import { useSseConnectionStore } from '@/lib/providers/sse-provider'; import { transformEntityDates } from '@/types/general'; -/** - * Type-safe SSE event handler hook. - * - * Features: - * - Strong typing (no `any` types) - * - Automatic event listener cleanup - * - Date transformation for consistency with REST API - * - Connection state tracking - * - Runtime type validation (optional) - * - * @template T - The expected event data type - * @param eventSource - The EventSource instance - * @param eventType - The SSE event type to listen for - * @param handler - Callback function to handle the event - * @param typeGuard - Optional runtime type guard for validation - */ -export function useSseEventHandler( +export function useSseEventHandler( eventSource: EventSource | null, - eventType: SseEventType, - handler: (data: T) => void, - typeGuard?: (data: unknown) => data is T + eventType: T, + handler: (event: Extract) => void ) { - // Use ref to avoid recreating listener on every render const handlerRef = useRef(handler); const recordEvent = useSseConnectionStore((store) => store.recordEvent); - // Update ref when handler changes useEffect(() => { handlerRef.current = handler; }, [handler]); @@ -662,26 +500,13 @@ export function useSseEventHandler( const listener = (e: MessageEvent) => { try { - // Parse event data - const parsed = JSON.parse(e.data); - - // Transform dates (consistent with REST API) - const transformed = transformEntityDates(parsed); - - // Optional runtime validation - if (typeGuard && !typeGuard(transformed.data)) { - console.error( - `[SSE] Type guard failed for event ${eventType}:`, - transformed.data - ); - return; - } + const parsed: SseEvent = JSON.parse(e.data); + const transformed = transformEntityDates(parsed) as SseEvent; - // Record event for connection health tracking - recordEvent(); - - // Invoke handler with typed data - handlerRef.current(transformed.data as T); + if (transformed.type === eventType) { + recordEvent(); + handlerRef.current(transformed as Extract); + } } catch (error) { console.error(`[SSE] Failed to parse ${eventType} event:`, error, e.data); } @@ -692,183 +517,80 @@ export function useSseEventHandler( return () => { eventSource.removeEventListener(eventType, listener); }; - }, [eventSource, eventType, typeGuard, recordEvent]); + }, [eventSource, eventType, recordEvent]); } ``` -**Why:** -- Type-safe (no `any` types) -- Date transformation for consistency -- Automatic cleanup prevents memory leaks -- Optional type guards for runtime validation - --- -### Phase 5: SWR Cache Invalidation Hook (THE KEY IMPROVEMENT) +### Phase 5: SWR Cache Invalidation Hook **File:** `src/lib/hooks/use-sse-cache-invalidation.ts` -This is the core architectural improvement over manual state updates. - ```typescript "use client"; -import { useCallback } from 'react'; import { useSWRConfig } from 'swr'; import { siteConfig } from '@/site.config'; import { useSseEventHandler } from './use-sse-event-handler'; -import type { - ActionCreatedData, - ActionUpdatedData, - ActionDeletedData, - AgreementCreatedData, - AgreementUpdatedData, - AgreementDeletedData, - GoalCreatedData, - GoalUpdatedData, - GoalDeletedData, -} from '@/types/sse-events'; -/** - * SSE cache invalidation hook that automatically revalidates SWR caches - * when SSE events are received. - * - * THIS IS THE SIMPLEST SOLUTION: - * - Rather than manually updating component state, we invalidate SWR caches - * - SWR automatically refetches the data - * - Existing components using useActionList, useAction, etc. require NO changes - * - Consistent with existing REST API patterns - * - Automatic deduplication and batching - * - * @param eventSource - The SSE connection - */ export function useSseCacheInvalidation(eventSource: EventSource | null) { const { mutate } = useSWRConfig(); const baseUrl = siteConfig.env.backendServiceURL; - // ==================== ACTION EVENTS ==================== - - useSseEventHandler( - eventSource, - 'action_created', - (data) => { - // Invalidate all action caches - mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/actions`)); - - console.log('[SSE] Revalidated actions for session:', data.coaching_session_id); - } - ); - - useSseEventHandler( - eventSource, - 'action_updated', - (data) => { - // Invalidate all action caches - mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/actions`)); - - console.log('[SSE] Revalidated action:', data.action.id); - } - ); - - useSseEventHandler( - eventSource, - 'action_deleted', - (data) => { - // Invalidate all action caches - mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/actions`)); - - console.log('[SSE] Revalidated actions after deletion:', data.action_id); - } - ); - - // ==================== AGREEMENT EVENTS ==================== - - useSseEventHandler( - eventSource, - 'agreement_created', - (data) => { - // Invalidate all agreement caches - mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/agreements`)); - - console.log('[SSE] Revalidated agreements for relationship:', data.coaching_relationship_id); - } - ); - - useSseEventHandler( - eventSource, - 'agreement_updated', - (data) => { - // Invalidate all agreement caches - mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/agreements`)); - - console.log('[SSE] Revalidated agreement:', data.agreement.id); - } - ); - - useSseEventHandler( - eventSource, - 'agreement_deleted', - (data) => { - // Invalidate all agreement caches - mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/agreements`)); + useSseEventHandler(eventSource, 'action_created', () => { + mutate((key) => typeof key === 'string' && key.includes(baseUrl)); + console.log('[SSE] Revalidated caches after action_created'); + }); - console.log('[SSE] Revalidated agreements after deletion:', data.agreement_id); - } - ); + useSseEventHandler(eventSource, 'action_updated', () => { + mutate((key) => typeof key === 'string' && key.includes(baseUrl)); + console.log('[SSE] Revalidated caches after action_updated'); + }); - // ==================== GOAL EVENTS ==================== + useSseEventHandler(eventSource, 'action_deleted', () => { + mutate((key) => typeof key === 'string' && key.includes(baseUrl)); + console.log('[SSE] Revalidated caches after action_deleted'); + }); - useSseEventHandler( - eventSource, - 'goal_created', - (data) => { - // Invalidate all goal caches - mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/overarching_goals`)); + useSseEventHandler(eventSource, 'agreement_created', () => { + mutate((key) => typeof key === 'string' && key.includes(baseUrl)); + console.log('[SSE] Revalidated caches after agreement_created'); + }); - console.log('[SSE] Revalidated goals for relationship:', data.coaching_relationship_id); - } - ); + useSseEventHandler(eventSource, 'agreement_updated', () => { + mutate((key) => typeof key === 'string' && key.includes(baseUrl)); + console.log('[SSE] Revalidated caches after agreement_updated'); + }); - useSseEventHandler( - eventSource, - 'goal_updated', - (data) => { - // Invalidate all goal caches - mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/overarching_goals`)); + useSseEventHandler(eventSource, 'agreement_deleted', () => { + mutate((key) => typeof key === 'string' && key.includes(baseUrl)); + console.log('[SSE] Revalidated caches after agreement_deleted'); + }); - console.log('[SSE] Revalidated goal:', data.goal.id); - } - ); + useSseEventHandler(eventSource, 'goal_created', () => { + mutate((key) => typeof key === 'string' && key.includes(baseUrl)); + console.log('[SSE] Revalidated caches after goal_created'); + }); - useSseEventHandler( - eventSource, - 'goal_deleted', - (data) => { - // Invalidate all goal caches - mutate((key) => typeof key === 'string' && key.includes(`${baseUrl}/overarching_goals`)); + useSseEventHandler(eventSource, 'goal_updated', () => { + mutate((key) => typeof key === 'string' && key.includes(baseUrl)); + console.log('[SSE] Revalidated caches after goal_updated'); + }); - console.log('[SSE] Revalidated goals after deletion:', data.goal_id); - } - ); + useSseEventHandler(eventSource, 'goal_deleted', () => { + mutate((key) => typeof key === 'string' && key.includes(baseUrl)); + console.log('[SSE] Revalidated caches after goal_deleted'); + }); } ``` -**Why:** -- **Simplest solution** - leverages existing SWR infrastructure -- **Zero component changes** - components using `useActionList` automatically get updated data -- **Consistent patterns** - matches `EntityApi.useEntityMutation` cache invalidation pattern -- **Broad invalidation** - simple `includes()` check invalidates all related caches safely -- **Automatic deduplication** - SWR handles multiple simultaneous revalidations -- **Built-in error handling** - SWR retry logic applies - --- ### Phase 6: System Events Hook **File:** `src/lib/hooks/use-sse-system-events.ts` -Handle system-level events (force logout) using existing auth infrastructure. - ```typescript "use client"; @@ -876,77 +598,60 @@ import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/providers/auth-store-provider'; import { useLogoutUser } from '@/lib/hooks/use-logout-user'; import { useSseEventHandler } from './use-sse-event-handler'; -import type { ForceLogoutData } from '@/types/sse-events'; -/** - * Handle system-level SSE events (force logout, etc.) - * Integrates with existing auth infrastructure rather than direct manipulation. - * - * @param eventSource - The SSE connection - */ export function useSseSystemEvents(eventSource: EventSource | null) { const router = useRouter(); const logout = useLogoutUser(); const isLoggedIn = useAuthStore((store) => store.isLoggedIn); - useSseEventHandler( - eventSource, - 'force_logout', - async (data) => { - console.warn('[SSE] Force logout received:', data.reason); - - if (isLoggedIn) { - // Use existing logout infrastructure (cleans up stores, calls API, etc.) - await logout(); + useSseEventHandler(eventSource, 'force_logout', async (event) => { + console.warn('[SSE] Force logout received:', event.data.reason); - // Redirect with reason - router.push(`/login?reason=forced_logout&message=${encodeURIComponent(data.reason)}`); - } + if (isLoggedIn) { + await logout(); + router.push(`/login?reason=forced_logout&message=${encodeURIComponent(event.data.reason)}`); } - ); + }); } ``` -**Why:** -- Uses existing `useLogoutUser` hook (proper cleanup) -- Integrates with auth store -- Consistent with existing logout flow -- Proper navigation (not `window.location.href`) - --- -### Phase 7: SSE Providers +### Phase 7: SSE Provider -**File:** `src/lib/providers/sse-connection-store-provider.tsx` - -Provider for SSE connection store (follows existing pattern). +**File:** `src/lib/providers/sse-provider.tsx` ```typescript "use client"; -import { - type ReactNode, - createContext, - useRef, - useContext, -} from 'react'; +import { type ReactNode, createContext, useRef, useContext } from 'react'; import { type StoreApi, useStore } from 'zustand'; import { type SseConnectionStore, createSseConnectionStore } from '@/lib/stores/sse-connection-store'; import { useShallow } from 'zustand/shallow'; +import { useAuthStore } from '@/lib/providers/auth-store-provider'; +import { useSseConnection } from '@/lib/hooks/use-sse-connection'; +import { useSseCacheInvalidation } from '@/lib/hooks/use-sse-cache-invalidation'; +import { useSseSystemEvents } from '@/lib/hooks/use-sse-system-events'; export const SseConnectionStoreContext = createContext | null>(null); -export interface SseConnectionStoreProviderProps { +export interface SseProviderProps { children: ReactNode; } -export const SseConnectionStoreProvider = ({ children }: SseConnectionStoreProviderProps) => { +export const SseProvider = ({ children }: SseProviderProps) => { const storeRef = useRef>(); if (!storeRef.current) { storeRef.current = createSseConnectionStore(); } + const isLoggedIn = useAuthStore((store) => store.isLoggedIn); + const eventSource = useSseConnection(isLoggedIn); + + useSseCacheInvalidation(eventSource); + useSseSystemEvents(eventSource); + return ( {children} @@ -960,81 +665,18 @@ export const useSseConnectionStore = ( const context = useContext(SseConnectionStoreContext); if (!context) { - throw new Error('useSseConnectionStore must be used within SseConnectionStoreProvider'); + throw new Error('useSseConnectionStore must be used within SseProvider'); } return useStore(context, useShallow(selector)); }; ``` -**File:** `src/lib/providers/sse-provider.tsx` - -Main SSE provider that composes all hooks. - -```typescript -"use client"; - -import { type ReactNode } from 'react'; -import { SseConnectionStoreProvider } from './sse-connection-store-provider'; -import { useAuthStore } from '@/lib/providers/auth-store-provider'; -import { useSseConnection } from '@/lib/hooks/use-sse-connection'; -import { useSseCacheInvalidation } from '@/lib/hooks/use-sse-cache-invalidation'; -import { useSseSystemEvents } from '@/lib/hooks/use-sse-system-events'; - -interface SseClientProps { - children: ReactNode; -} - -/** - * Internal component that establishes SSE connection and sets up event handlers. - * Must be rendered inside SseConnectionStoreProvider and AuthStoreProvider. - */ -function SseClient({ children }: SseClientProps) { - const isLoggedIn = useAuthStore((store) => store.isLoggedIn); - const eventSource = useSseConnection(isLoggedIn); - - // Auto-invalidate SWR caches on events - useSseCacheInvalidation(eventSource); - - // Handle system events (force logout) - useSseSystemEvents(eventSource); - - return <>{children}; -} - -/** - * Main SSE provider that wraps the application. - * Establishes single app-wide SSE connection and handles all events. - * - * Usage: - * ```tsx - * - * - * - * ``` - */ -export function SseProvider({ children }: SseClientProps) { - return ( - - {children} - - ); -} -``` - -**Why:** -- Follows existing provider pattern (AuthStoreProvider, OrganizationStateStoreProvider) -- Clean separation of concerns -- Composes hooks cleanly -- Single app-wide connection - --- ### Phase 8: Integrate into Root Providers -**File:** `src/components/providers.tsx` (updated) - -Add SSE provider to existing provider composition. +**File:** `src/components/providers.tsx` ```typescript "use client"; @@ -1044,7 +686,7 @@ import { AuthStoreProvider } from '@/lib/providers/auth-store-provider'; import { OrganizationStateStoreProvider } from '@/lib/providers/organization-state-store-provider'; import { CoachingRelationshipStateStoreProvider } from '@/lib/providers/coaching-relationship-state-store-provider'; import { SessionCleanupProvider } from '@/lib/providers/session-cleanup-provider'; -import { SseProvider } from '@/lib/providers/sse-provider'; // NEW +import { SseProvider } from '@/lib/providers/sse-provider'; import { SWRConfig } from 'swr'; interface ProvidersProps { @@ -1064,7 +706,6 @@ export function Providers({ children }: ProvidersProps) { provider: () => new Map(), }} > - {/* NEW: SSE Provider establishes connection and handles events */} {children} @@ -1077,46 +718,25 @@ export function Providers({ children }: ProvidersProps) { } ``` -**Why:** -- Consistent with existing pattern -- SSE connection established once for entire app -- Events handled automatically -- Zero changes needed in existing components - --- ### Phase 9 (Optional): Connection Status UI **File:** `src/components/sse-connection-indicator.tsx` -Optional visual indicator for connection status. - ```typescript "use client"; -import { useSseConnectionStore } from '@/lib/providers/sse-connection-store-provider'; +import { useSseConnectionStore } from '@/lib/providers/sse-provider'; import { SseConnectionState } from '@/lib/stores/sse-connection-store'; import { AlertCircle, Loader2, WifiOff } from 'lucide-react'; -/** - * Optional UI component to display SSE connection status. - * Non-intrusive - only shows when NOT connected. - * - * Can be placed in app header/status bar: - * ```tsx - *
- * - *
- * ``` - */ export function SseConnectionIndicator() { - const { state, lastError, reconnectAttempts } = useSseConnectionStore((store) => ({ + const { state, lastError } = useSseConnectionStore((store) => ({ state: store.state, lastError: store.lastError, - reconnectAttempts: store.reconnectAttempts, })); - // Don't show anything when connected (non-intrusive) if (state === SseConnectionState.Connected) { return null; } @@ -1124,7 +744,6 @@ export function SseConnectionIndicator() { const getIcon = () => { switch (state) { case SseConnectionState.Connecting: - case SseConnectionState.Reconnecting: return ; case SseConnectionState.Error: return ; @@ -1139,8 +758,6 @@ export function SseConnectionIndicator() { switch (state) { case SseConnectionState.Connecting: return 'Connecting to live updates...'; - case SseConnectionState.Reconnecting: - return `Reconnecting (attempt ${reconnectAttempts})...`; case SseConnectionState.Error: return lastError?.message || 'Connection error'; case SseConnectionState.Disconnected: @@ -1153,7 +770,6 @@ export function SseConnectionIndicator() { const getColorClass = () => { switch (state) { case SseConnectionState.Connecting: - case SseConnectionState.Reconnecting: return 'text-yellow-600 dark:text-yellow-400'; case SseConnectionState.Error: return 'text-red-600 dark:text-red-400'; @@ -1173,39 +789,16 @@ export function SseConnectionIndicator() { } ``` -**Usage:** -```tsx -// In app header or status bar -import { SseConnectionIndicator } from '@/components/sse-connection-indicator'; - -export function AppHeader() { - return ( -
- - {/* other header content */} -
- ); -} -``` - -**Why:** -- Non-intrusive (hidden when connected) -- Visual feedback for users -- Useful for debugging -- Optional (not required for functionality) - --- ## Testing Strategy ### Unit Tests -**Test SSE Event Handler:** ```typescript // src/lib/hooks/__tests__/use-sse-event-handler.test.ts import { renderHook } from '@testing-library/react'; import { useSseEventHandler } from '../use-sse-event-handler'; -import type { ActionCreatedData } from '@/types/sse-events'; describe('useSseEventHandler', () => { it('should handle action_created events with correct typing', () => { @@ -1213,84 +806,35 @@ describe('useSseEventHandler', () => { const mockHandler = jest.fn(); renderHook(() => - useSseEventHandler( - mockEventSource, - 'action_created', - mockHandler - ) + useSseEventHandler(mockEventSource, 'action_created', mockHandler) ); - // Trigger event const event = new MessageEvent('action_created', { data: JSON.stringify({ type: 'action_created', data: { coaching_session_id: 'session-123', - action: { id: 'action-1', /* ... */ } + action: { id: 'action-1' } } }) }); mockEventSource.dispatchEvent(event); - // Verify handler called with typed data expect(mockHandler).toHaveBeenCalledWith( expect.objectContaining({ - coaching_session_id: 'session-123', - action: expect.objectContaining({ id: 'action-1' }) - }) - ); - }); -}); -``` - -**Test SWR Cache Invalidation:** -```typescript -// src/lib/hooks/__tests__/use-sse-cache-invalidation.test.ts -import { renderHook, waitFor } from '@testing-library/react'; -import { useSseCacheInvalidation } from '../use-sse-cache-invalidation'; -import { useActionList } from '@/lib/api/actions'; -import { mutate } from 'swr'; - -jest.mock('swr', () => ({ - useSWRConfig: () => ({ mutate: jest.fn() }), -})); - -describe('useSseCacheInvalidation', () => { - it('should revalidate action cache on action_created event', async () => { - const mockEventSource = new EventSource('mock-url'); - const mockMutate = jest.fn(); - - (mutate as jest.Mock).mockImplementation(mockMutate); - - renderHook(() => useSseCacheInvalidation(mockEventSource)); - - // Trigger SSE event - const event = new MessageEvent('action_created', { - data: JSON.stringify({ type: 'action_created', - data: { - coaching_session_id: 'session-123', - action: { id: 'action-1' } - } + data: expect.objectContaining({ + coaching_session_id: 'session-123' + }) }) - }); - - mockEventSource.dispatchEvent(event); - - // Verify SWR cache was invalidated - await waitFor(() => { - expect(mockMutate).toHaveBeenCalledWith( - expect.stringContaining('/actions?coaching_session_id=session-123') - ); - }); + ); }); }); ``` ### Integration Tests -**Test End-to-End Flow:** ```typescript // Integration test: SSE event → cache invalidation → component update import { render, screen, waitFor } from '@testing-library/react'; @@ -1299,17 +843,14 @@ import { CoachingSessionPage } from '@/app/coaching-sessions/[id]/page'; describe('SSE Integration', () => { it('should update action list when action_created event received', async () => { - // Render component with SSE provider render( ); - // Initial state: no actions expect(screen.queryByText('Test Action')).not.toBeInTheDocument(); - // Simulate SSE event from backend const event = new MessageEvent('action_created', { data: JSON.stringify({ type: 'action_created', @@ -1322,7 +863,6 @@ describe('SSE Integration', () => { window.EventSource.prototype.dispatchEvent(event); - // Wait for SWR to refetch and component to update await waitFor(() => { expect(screen.getByText('Test Action')).toBeInTheDocument(); }); @@ -1334,66 +874,36 @@ describe('SSE Integration', () => { ## Security Considerations -### Authentication - SSE endpoint requires valid session cookie (handled by `withCredentials: true`) - Backend validates session via `AuthenticatedUser` extractor -- No additional auth tokens needed - -### Authorization - Backend determines event recipients (not client-controlled) -- Events only sent to authorized users -- Frontend cannot request events for other users - -### Data Validation - Type guards validate event structure at runtime -- Prevents malformed events from crashing UI -- Console errors logged for debugging - -### Connection Security - HTTPS enforced in production (via nginx) - Session cookies are httpOnly and secure -- No sensitive data in event stream (just IDs and references) --- ## Performance Considerations -### Connection Overhead -- **Single connection per user** (not per component) +- Single connection per user (not per component) - Minimal bandwidth (only events for this user) -- Keep-alive messages every 15s (small overhead) - -### Memory Usage -- EventSource object: ~1KB per connection -- Event handlers: minimal (closures with refs) -- Zustand store: ~1KB for connection state - -### Network Usage -- Events only sent when changes occur -- No polling (reduces bandwidth vs. polling solutions) -- Automatic reconnection prevents connection leaks - -### SWR Cache Behavior -- Multiple simultaneous invalidations are deduplicated by SWR -- SWR batches requests within 2 seconds -- Existing SWR cache stays fresh without polling +- Keep-alive messages every 15s prevent timeouts +- Native browser reconnection prevents connection leaks +- SWR deduplicates simultaneous cache invalidations +- Broad cache invalidation leverages SWR's batching --- ## Debugging Guide -### Connection Issues - -**Check connection state:** +### Check connection state: ```typescript -// In any component -import { useSseConnectionStore } from '@/lib/providers/sse-connection-store-provider'; +import { useSseConnectionStore } from '@/lib/providers/sse-provider'; function DebugComponent() { const connectionState = useSseConnectionStore((store) => ({ state: store.state, lastError: store.lastError, - reconnectAttempts: store.reconnectAttempts, lastConnectedAt: store.lastConnectedAt, lastEventAt: store.lastEventAt, })); @@ -1402,129 +912,23 @@ function DebugComponent() { } ``` -**Check browser DevTools:** +### Check Redux DevTools: 1. Open Redux DevTools → `sse-connection-store` 2. View connection state, errors, timestamps 3. Monitor state transitions -**Check browser console:** -``` -[SSE] Connection established -[SSE] Revalidated actions for session: abc-123 -[SSE] Connection error: Failed to fetch -[SSE] Reconnecting in 1500ms... -``` - -### Event Flow Issues - -**Verify events are received:** -```typescript -// Add debug handler -useSseEventHandler( - eventSource, - 'action_created', - (data) => { - console.log('[DEBUG] Received action_created:', data); - } -); -``` - -**Check SWR cache keys:** -```typescript -// In browser console -window.swr = useSWRConfig(); -console.log(window.swr.cache.keys()); -``` - -**Verify cache invalidation:** -```typescript -// Watch for mutate calls -const originalMutate = mutate; -mutate = (...args) => { - console.log('[DEBUG] SWR mutate called:', args[0]); - return originalMutate(...args); -}; -``` - ---- - -## Migration from Polling (If Applicable) - -If currently using polling, migration is straightforward: - -**Before (Polling):** -```typescript -// Remove polling interval -useEffect(() => { - const interval = setInterval(() => { - refresh(); // Manual refresh every 5 seconds - }, 5000); - return () => clearInterval(interval); -}, [refresh]); -``` - -**After (SSE):** -```typescript -// No changes needed in component! -// SSE automatically triggers cache invalidation -// Components using useActionList automatically get updates -``` - -**Benefits:** -- Reduced server load (no constant polling) -- Lower latency (events arrive immediately) -- Less bandwidth usage -- Better battery life (mobile devices) - ---- - -## Future Enhancements (Out of Scope) - -### Advanced Features -- **Optimistic updates**: Update UI before event arrives, rollback on error -- **Event replay**: Persist critical events for offline users -- **Presence indicators**: Show who's viewing a session -- **Typing indicators**: Real-time collaboration features -- **Conflict resolution**: Handle simultaneous edits - -### Additional Event Types -- Session lifecycle: `session_started`, `session_ended` -- User presence: `user_joined_session`, `user_left_session` -- Notifications: `notification_created`, `notification_dismissed` - -### Performance Optimizations -- **Event batching**: Group multiple events into single message -- **Compression**: Enable gzip for event stream -- **Selective subscriptions**: Subscribe only to relevant event types - ---- - -## References - -### Documentation -- [MDN Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) -- [SWR Documentation](https://swr.vercel.app/) -- [Zustand Documentation](https://docs.pmnd.rs/zustand) -- [Next.js Client Components](https://nextjs.org/docs/app/building-your-application/rendering/client-components) - -### Backend Integration -- See `docs/implementation-plans/sse-communication.md` in backend repo for: - - SSE event format - - Authentication flow - - Nginx configuration - - Message routing logic - --- ## Summary This implementation provides a robust, type-safe, and maintainable SSE solution that: -✅ **Follows existing patterns** (SWR, Zustand, hooks, providers) -✅ **Requires zero component changes** (automatic cache invalidation) -✅ **Strongly typed throughout** (no `any` types) -✅ **Handles failures gracefully** (automatic reconnection with backoff) -✅ **Integrates seamlessly** (with existing auth, routing, caching) -✅ **Production-ready** (error tracking, debugging, monitoring) +✅ Uses native browser APIs (no external dependencies) +✅ Follows existing patterns (SWR, Zustand, hooks, providers, logout cleanup) +✅ Requires zero component changes (automatic cache invalidation) +✅ Strongly typed throughout (discriminated unions) +✅ Handles failures gracefully (automatic reconnection) +✅ Integrates seamlessly (with existing auth, routing, caching) +✅ Production-ready (error tracking, debugging, monitoring) The key insight: **leverage SWR cache invalidation instead of manual state updates**. This is simpler, more reliable, and consistent with existing codebase patterns. From df0bd883c15b06b885a794f0a3bdfddbe9a61efb Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Tue, 16 Dec 2025 08:21:08 -0500 Subject: [PATCH 04/19] Document provider initialization order for SSE implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add note clarifying that AuthStoreProvider's deferred initialization causes two render cycles at root level, but SseProvider only mounts once when AuthStoreContext is available. This guarantees safe access to hydrated auth state when establishing SSE connection. Addresses PR feedback on provider dependency chain analysis. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/implementation-plans/sse-communication.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/implementation-plans/sse-communication.md b/docs/implementation-plans/sse-communication.md index a1f63660..2a0bd99b 100644 --- a/docs/implementation-plans/sse-communication.md +++ b/docs/implementation-plans/sse-communication.md @@ -672,6 +672,11 @@ export const useSseConnectionStore = ( }; ``` +**Note on Provider Initialization Order:** +`AuthStoreProvider` returns `null` until `useEffect` hydrates from localStorage, causing two render cycles at root. +However, `SseProvider` only mounts once (during second cycle) when `AuthStoreContext` is already available. +This guarantees `useAuthStore((store) => store.isLoggedIn)` reads hydrated state and SSE connects safely if authenticated. + --- ### Phase 8: Integrate into Root Providers From 4670925cd94fe5f564e35a6e1c6e7d0ac9e2f53f Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Tue, 16 Dec 2025 08:39:26 -0500 Subject: [PATCH 05/19] Add Priority 1 improvements to SSE implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Extract SSE connection context to prevent circular imports - New file: src/lib/contexts/sse-connection-context.tsx - Separates context/hook from provider (matches auth pattern) - Fixes circular dependency between sse-provider.tsx and use-sse-connection.ts 2. Add Reconnecting state to connection state enum - Distinguishes initial connection from reconnection attempts - Enables accurate UI feedback for users 3. Update connection hook to check EventSource.readyState - Detects network errors (auto-reconnect) vs HTTP errors (permanent failure) - Sets Reconnecting state when browser attempts reconnection - Closes connection on permanent failures (401, 403, 500, etc.) 4. Update all import paths to use extracted context - use-sse-connection.ts, use-sse-event-handler.ts, sse-provider.tsx - Connection indicator component includes Reconnecting state Addresses PR feedback on circular imports and reconnection state tracking. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../implementation-plans/sse-communication.md | 92 ++++++++++++++----- 1 file changed, 70 insertions(+), 22 deletions(-) diff --git a/docs/implementation-plans/sse-communication.md b/docs/implementation-plans/sse-communication.md index 2a0bd99b..fa49bcba 100644 --- a/docs/implementation-plans/sse-communication.md +++ b/docs/implementation-plans/sse-communication.md @@ -165,6 +165,9 @@ src/ │ ├── stores/ │ │ └── sse-connection-store.ts # Connection state management │ │ +│ ├── contexts/ +│ │ └── sse-connection-context.tsx # Store context (prevents circular imports) +│ │ │ ├── hooks/ │ │ ├── use-sse-connection.ts # Native EventSource connection │ │ ├── use-sse-event-handler.ts # Type-safe event handler @@ -320,6 +323,7 @@ export enum SseConnectionState { Disconnected = "Disconnected", Connecting = "Connecting", Connected = "Connected", + Reconnecting = "Reconnecting", Error = "Error", } @@ -339,6 +343,7 @@ interface SseConnectionStateData { interface SseConnectionActions { setConnecting: () => void; setConnected: () => void; + setReconnecting: () => void; setError: (error: string) => void; setDisconnected: () => void; recordEvent: () => void; @@ -371,6 +376,10 @@ export const createSseConnectionStore = () => { }); }, + setReconnecting: () => { + set({ state: SseConnectionState.Reconnecting }); + }, + setError: (message: string) => { set({ state: SseConnectionState.Error, @@ -398,6 +407,43 @@ export const createSseConnectionStore = () => { --- +### Phase 2.5: SSE Connection Context (Prevent Circular Imports) + +**File:** `src/lib/contexts/sse-connection-context.tsx` + +Extract the context and hook to prevent circular imports between `sse-provider.tsx` and `use-sse-connection.ts`. + +```typescript +"use client"; + +import { createContext, useContext } from 'react'; +import { type StoreApi, useStore } from 'zustand'; +import { type SseConnectionStore } from '@/lib/stores/sse-connection-store'; +import { useShallow } from 'zustand/shallow'; + +export const SseConnectionStoreContext = createContext | null>(null); + +export const useSseConnectionStore = ( + selector: (store: SseConnectionStore) => T +): T => { + const context = useContext(SseConnectionStoreContext); + + if (!context) { + throw new Error('useSseConnectionStore must be used within SseProvider'); + } + + return useStore(context, useShallow(selector)); +}; +``` + +**Why this is needed:** +- `use-sse-connection.ts` needs `useSseConnectionStore` +- `sse-provider.tsx` needs `useSseConnection` +- Without this extraction, we have a circular dependency +- Follows the same pattern as `AuthStoreProvider` / `useAuthStore` + +--- + ### Phase 3: SSE Connection Hook (Native EventSource) **File:** `src/lib/hooks/use-sse-connection.ts` @@ -407,7 +453,7 @@ export const createSseConnectionStore = () => { import { useEffect, useRef } from 'react'; import { siteConfig } from '@/site.config'; -import { useSseConnectionStore } from '@/lib/providers/sse-provider'; +import { useSseConnectionStore } from '@/lib/contexts/sse-connection-context'; import { logoutCleanupRegistry } from '@/lib/hooks/logout-cleanup-registry'; export function useSseConnection(isLoggedIn: boolean) { @@ -416,12 +462,14 @@ export function useSseConnection(isLoggedIn: boolean) { const { setConnecting, setConnected, + setReconnecting, setError, setDisconnected, recordEvent, } = useSseConnectionStore((store) => ({ setConnecting: store.setConnecting, setConnected: store.setConnected, + setReconnecting: store.setReconnecting, setError: store.setError, setDisconnected: store.setDisconnected, recordEvent: store.recordEvent, @@ -448,7 +496,17 @@ export function useSseConnection(isLoggedIn: boolean) { source.onerror = (error) => { console.error('[SSE] Connection error:', error); - setError('Connection error - browser will auto-reconnect'); + + // Check readyState to distinguish network errors from HTTP errors + if (source.readyState === EventSource.CONNECTING) { + // Browser is attempting reconnection (network error) + setReconnecting(); + console.log('[SSE] Connection lost, browser attempting reconnection...'); + } else { + // EventSource.CLOSED - permanent failure (HTTP error like 401, 403, 500) + setError('Connection failed - check authentication or server status'); + source.close(); + } }; eventSourceRef.current = source; @@ -463,7 +521,7 @@ export function useSseConnection(isLoggedIn: boolean) { setDisconnected(); unregisterCleanup(); }; - }, [isLoggedIn, setConnecting, setConnected, setError, setDisconnected]); + }, [isLoggedIn, setConnecting, setConnected, setReconnecting, setError, setDisconnected]); return eventSourceRef.current; } @@ -480,7 +538,7 @@ export function useSseConnection(isLoggedIn: boolean) { import { useEffect, useRef } from 'react'; import type { SseEvent } from '@/types/sse-events'; -import { useSseConnectionStore } from '@/lib/providers/sse-provider'; +import { useSseConnectionStore } from '@/lib/contexts/sse-connection-context'; import { transformEntityDates } from '@/types/general'; export function useSseEventHandler( @@ -624,17 +682,15 @@ export function useSseSystemEvents(eventSource: EventSource | null) { ```typescript "use client"; -import { type ReactNode, createContext, useRef, useContext } from 'react'; -import { type StoreApi, useStore } from 'zustand'; +import { type ReactNode, useRef } from 'react'; +import { type StoreApi } from 'zustand'; import { type SseConnectionStore, createSseConnectionStore } from '@/lib/stores/sse-connection-store'; -import { useShallow } from 'zustand/shallow'; +import { SseConnectionStoreContext } from '@/lib/contexts/sse-connection-context'; import { useAuthStore } from '@/lib/providers/auth-store-provider'; import { useSseConnection } from '@/lib/hooks/use-sse-connection'; import { useSseCacheInvalidation } from '@/lib/hooks/use-sse-cache-invalidation'; import { useSseSystemEvents } from '@/lib/hooks/use-sse-system-events'; -export const SseConnectionStoreContext = createContext | null>(null); - export interface SseProviderProps { children: ReactNode; } @@ -658,18 +714,6 @@ export const SseProvider = ({ children }: SseProviderProps) => {
); }; - -export const useSseConnectionStore = ( - selector: (store: SseConnectionStore) => T -): T => { - const context = useContext(SseConnectionStoreContext); - - if (!context) { - throw new Error('useSseConnectionStore must be used within SseProvider'); - } - - return useStore(context, useShallow(selector)); -}; ``` **Note on Provider Initialization Order:** @@ -732,7 +776,7 @@ export function Providers({ children }: ProvidersProps) { ```typescript "use client"; -import { useSseConnectionStore } from '@/lib/providers/sse-provider'; +import { useSseConnectionStore } from '@/lib/contexts/sse-connection-context'; import { SseConnectionState } from '@/lib/stores/sse-connection-store'; import { AlertCircle, Loader2, WifiOff } from 'lucide-react'; @@ -749,6 +793,7 @@ export function SseConnectionIndicator() { const getIcon = () => { switch (state) { case SseConnectionState.Connecting: + case SseConnectionState.Reconnecting: return ; case SseConnectionState.Error: return ; @@ -763,6 +808,8 @@ export function SseConnectionIndicator() { switch (state) { case SseConnectionState.Connecting: return 'Connecting to live updates...'; + case SseConnectionState.Reconnecting: + return 'Reconnecting to live updates...'; case SseConnectionState.Error: return lastError?.message || 'Connection error'; case SseConnectionState.Disconnected: @@ -775,6 +822,7 @@ export function SseConnectionIndicator() { const getColorClass = () => { switch (state) { case SseConnectionState.Connecting: + case SseConnectionState.Reconnecting: return 'text-yellow-600 dark:text-yellow-400'; case SseConnectionState.Error: return 'text-red-600 dark:text-red-400'; From cf926e0739410128688faa5039d6e70250e2f951 Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Thu, 18 Dec 2025 06:52:09 -0500 Subject: [PATCH 06/19] Standardize OverarchingGoal date fields to use DateTime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update OverarchingGoal interface to use ts-luxon DateTime instead of string for date fields (status_changed_at, completed_at, created_at, updated_at). This aligns with the Action and Agreement types for consistency across the codebase. Also extend transformEntityDates helper to handle the additional date fields (status_changed_at, completed_at) used by OverarchingGoal. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/types/general.ts | 2 ++ src/types/overarching-goal.ts | 18 ++++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/types/general.ts b/src/types/general.ts index 41e22fda..4f04ad91 100644 --- a/src/types/general.ts +++ b/src/types/general.ts @@ -160,6 +160,8 @@ export const transformEntityDates = (data: any): any => { convertDate("created_at"); convertDate("updated_at"); convertDate("due_by"); + convertDate("status_changed_at"); + convertDate("completed_at"); return transformed; }; diff --git a/src/types/overarching-goal.ts b/src/types/overarching-goal.ts index 740ea61b..4a2b5c92 100644 --- a/src/types/overarching-goal.ts +++ b/src/types/overarching-goal.ts @@ -1,3 +1,4 @@ +import { DateTime } from "ts-luxon"; import { Id, ItemStatus } from "@/types/general"; // This must always reflect the Rust struct on the backend @@ -9,10 +10,10 @@ export interface OverarchingGoal { title: string; body: string; status: ItemStatus; - status_changed_at: string; - completed_at: string; - created_at: string; - updated_at: string; + status_changed_at: DateTime; + completed_at: DateTime; + created_at: DateTime; + updated_at: DateTime; } export function parseOverarchingGoal(data: any): OverarchingGoal { @@ -88,6 +89,7 @@ export function getOverarchingGoalById( } export function defaultOverarchingGoal(): OverarchingGoal { + const now = DateTime.now(); return { id: "", coaching_session_id: "", @@ -95,10 +97,10 @@ export function defaultOverarchingGoal(): OverarchingGoal { title: "", body: "", status: ItemStatus.NotStarted, - status_changed_at: "", - completed_at: "", - created_at: "", - updated_at: "", + status_changed_at: now, + completed_at: now, + created_at: now, + updated_at: now, }; } From 48989ecb5551b796ede46fe1607f503b0c68519b Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Thu, 18 Dec 2025 06:52:18 -0500 Subject: [PATCH 07/19] Add SSE event type definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create strongly-typed SSE event definitions using discriminated unions that match the backend's Rust serialization format. Includes: - Action events (action_created, action_updated, action_deleted) - Agreement events (agreement_created, agreement_updated, agreement_deleted) - Overarching goal events (overarching_goal_created, overarching_goal_updated, overarching_goal_deleted) - System events (force_logout) TypeScript automatically narrows event types based on the 'type' property, providing compile-time safety and IntelliSense support. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/types/sse-events.ts | 116 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/types/sse-events.ts diff --git a/src/types/sse-events.ts b/src/types/sse-events.ts new file mode 100644 index 00000000..f75c80fc --- /dev/null +++ b/src/types/sse-events.ts @@ -0,0 +1,116 @@ +import type { Id } from './general'; +import type { Action } from './action'; +import type { Agreement } from './agreement'; +import type { OverarchingGoal } from './overarching-goal'; + +/** + * Base SSE event structure matching backend serialization + * Backend uses Rust #[serde(tag = "type", content = "data")] + */ +interface BaseSseEvent { + type: T; + data: D; +} + +// ==================== ACTION EVENTS (session-scoped) ==================== + +export type ActionCreatedEvent = BaseSseEvent< + 'action_created', + { + coaching_session_id: Id; + action: Action; + } +>; + +export type ActionUpdatedEvent = BaseSseEvent< + 'action_updated', + { + coaching_session_id: Id; + action: Action; + } +>; + +export type ActionDeletedEvent = BaseSseEvent< + 'action_deleted', + { + coaching_session_id: Id; + action_id: Id; + } +>; + +// ==================== AGREEMENT EVENTS (relationship-scoped) ==================== + +export type AgreementCreatedEvent = BaseSseEvent< + 'agreement_created', + { + coaching_relationship_id: Id; + agreement: Agreement; + } +>; + +export type AgreementUpdatedEvent = BaseSseEvent< + 'agreement_updated', + { + coaching_relationship_id: Id; + agreement: Agreement; + } +>; + +export type AgreementDeletedEvent = BaseSseEvent< + 'agreement_deleted', + { + coaching_relationship_id: Id; + agreement_id: Id; + } +>; + +// ==================== OVERARCHING GOAL EVENTS (relationship-scoped) ==================== + +export type OverarchingGoalCreatedEvent = BaseSseEvent< + 'overarching_goal_created', + { + coaching_relationship_id: Id; + overarching_goal: OverarchingGoal; + } +>; + +export type OverarchingGoalUpdatedEvent = BaseSseEvent< + 'overarching_goal_updated', + { + coaching_relationship_id: Id; + overarching_goal: OverarchingGoal; + } +>; + +export type OverarchingGoalDeletedEvent = BaseSseEvent< + 'overarching_goal_deleted', + { + coaching_relationship_id: Id; + overarching_goal_id: Id; + } +>; + +// ==================== SYSTEM EVENTS ==================== + +export type ForceLogoutEvent = BaseSseEvent< + 'force_logout', + { + reason: string; + } +>; + +/** + * Discriminated union of all SSE events + * TypeScript automatically narrows the type based on the 'type' property + */ +export type SseEvent = + | ActionCreatedEvent + | ActionUpdatedEvent + | ActionDeletedEvent + | AgreementCreatedEvent + | AgreementUpdatedEvent + | AgreementDeletedEvent + | OverarchingGoalCreatedEvent + | OverarchingGoalUpdatedEvent + | OverarchingGoalDeletedEvent + | ForceLogoutEvent; From 322c642611aaf271e3c1b98065c811a5bc40b632 Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Thu, 18 Dec 2025 06:52:28 -0500 Subject: [PATCH 08/19] Add SSE connection state management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create Zustand store for SSE connection state tracking with: - Connection states: Disconnected, Connecting, Connected, Reconnecting, Error - Error tracking with timestamps and attempt numbers - Event tracking (lastConnectedAt, lastEventAt) - DevTools integration Extract context to prevent circular imports between provider and hooks, following the same pattern as AuthStoreProvider. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/lib/contexts/sse-connection-context.tsx | 20 +++++ src/lib/stores/sse-connection-store.ts | 87 +++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/lib/contexts/sse-connection-context.tsx create mode 100644 src/lib/stores/sse-connection-store.ts diff --git a/src/lib/contexts/sse-connection-context.tsx b/src/lib/contexts/sse-connection-context.tsx new file mode 100644 index 00000000..fbe55047 --- /dev/null +++ b/src/lib/contexts/sse-connection-context.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { createContext, useContext } from 'react'; +import { type StoreApi, useStore } from 'zustand'; +import { type SseConnectionStore } from '@/lib/stores/sse-connection-store'; +import { useShallow } from 'zustand/shallow'; + +export const SseConnectionStoreContext = createContext | undefined>(undefined); + +export const useSseConnectionStore = ( + selector: (store: SseConnectionStore) => T +): T => { + const context = useContext(SseConnectionStoreContext); + + if (!context) { + throw new Error('useSseConnectionStore must be used within SseProvider'); + } + + return useStore(context, useShallow(selector)); +}; diff --git a/src/lib/stores/sse-connection-store.ts b/src/lib/stores/sse-connection-store.ts new file mode 100644 index 00000000..067c39dd --- /dev/null +++ b/src/lib/stores/sse-connection-store.ts @@ -0,0 +1,87 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +export enum SseConnectionState { + Disconnected = "Disconnected", + Connecting = "Connecting", + Connected = "Connected", + Reconnecting = "Reconnecting", + Error = "Error", +} + +interface SseError { + message: string; + timestamp: Date; + attemptNumber: number; +} + +interface SseConnectionStateData { + state: SseConnectionState; + lastError: SseError | null; + lastConnectedAt: Date | null; + lastEventAt: Date | null; +} + +interface SseConnectionActions { + setConnecting: () => void; + setConnected: () => void; + setReconnecting: () => void; + setError: (error: string) => void; + setDisconnected: () => void; + recordEvent: () => void; +} + +export type SseConnectionStore = SseConnectionStateData & SseConnectionActions; + +const defaultState: SseConnectionStateData = { + state: SseConnectionState.Disconnected, + lastError: null, + lastConnectedAt: null, + lastEventAt: null, +}; + +export const createSseConnectionStore = () => { + return create()( + devtools( + (set) => ({ + ...defaultState, + + setConnecting: () => { + set({ state: SseConnectionState.Connecting }); + }, + + setConnected: () => { + set({ + state: SseConnectionState.Connected, + lastConnectedAt: new Date(), + lastError: null, + }); + }, + + setReconnecting: () => { + set({ state: SseConnectionState.Reconnecting }); + }, + + setError: (message: string) => { + set({ + state: SseConnectionState.Error, + lastError: { + message, + timestamp: new Date(), + attemptNumber: 0, + }, + }); + }, + + setDisconnected: () => { + set(defaultState); + }, + + recordEvent: () => { + set({ lastEventAt: new Date() }); + }, + }), + { name: 'sse-connection-store' } + ) + ); +}; From 6292a0eaf2b4938a5b68a81eb2a4130f161bd5b0 Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Thu, 18 Dec 2025 06:52:38 -0500 Subject: [PATCH 09/19] Add SSE connection and event handler hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement core SSE infrastructure: - use-sse-connection: Manages native EventSource connection with automatic reconnection, connection state tracking, and integration with logout cleanup registry. Follows browser standards for SSE. - use-sse-event-handler: Type-safe event handler hook using handler ref pattern to prevent listener re-registration. Includes automatic date transformation via transformEntityDates and event tracking. Both hooks integrate with the SSE connection store for centralized state management. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/lib/hooks/use-sse-connection.ts | 74 ++++++++++++++++++++++++++ src/lib/hooks/use-sse-event-handler.ts | 43 +++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 src/lib/hooks/use-sse-connection.ts create mode 100644 src/lib/hooks/use-sse-event-handler.ts diff --git a/src/lib/hooks/use-sse-connection.ts b/src/lib/hooks/use-sse-connection.ts new file mode 100644 index 00000000..5e21b585 --- /dev/null +++ b/src/lib/hooks/use-sse-connection.ts @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useRef } from 'react'; +import { siteConfig } from '@/site.config'; +import { useSseConnectionStore } from '@/lib/contexts/sse-connection-context'; +import { logoutCleanupRegistry } from '@/lib/hooks/logout-cleanup-registry'; + +export function useSseConnection(isLoggedIn: boolean) { + const eventSourceRef = useRef(null); + + const { + setConnecting, + setConnected, + setReconnecting, + setError, + setDisconnected, + } = useSseConnectionStore((store) => ({ + setConnecting: store.setConnecting, + setConnected: store.setConnected, + setReconnecting: store.setReconnecting, + setError: store.setError, + setDisconnected: store.setDisconnected, + })); + + useEffect(() => { + if (!isLoggedIn) { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + setDisconnected(); + return; + } + + setConnecting(); + + const source = new EventSource(`${siteConfig.env.backendServiceURL}/sse`, { + withCredentials: true, + }); + + source.onopen = () => { + console.log('[SSE] Connection established'); + setConnected(); + }; + + source.onerror = (error) => { + console.error('[SSE] Connection error:', error); + + // Check readyState to distinguish network errors from HTTP errors + if (source.readyState === EventSource.CONNECTING) { + // Browser is attempting reconnection (network error) + setReconnecting(); + console.log('[SSE] Connection lost, browser attempting reconnection...'); + } else { + // EventSource.CLOSED - permanent failure (HTTP error like 401, 403, 500) + setError('Connection failed - check authentication or server status'); + source.close(); + } + }; + + eventSourceRef.current = source; + + const unregisterCleanup = logoutCleanupRegistry.register(() => { + console.log('[SSE] Cleaning up connection on logout'); + source.close(); + }); + + return () => { + source.close(); + setDisconnected(); + unregisterCleanup(); + }; + }, [isLoggedIn, setConnecting, setConnected, setReconnecting, setError, setDisconnected]); + + return eventSourceRef.current; +} diff --git a/src/lib/hooks/use-sse-event-handler.ts b/src/lib/hooks/use-sse-event-handler.ts new file mode 100644 index 00000000..d87e9c54 --- /dev/null +++ b/src/lib/hooks/use-sse-event-handler.ts @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useRef } from 'react'; +import type { SseEvent } from '@/types/sse-events'; +import { useSseConnectionStore } from '@/lib/contexts/sse-connection-context'; +import { transformEntityDates } from '@/types/general'; + +export function useSseEventHandler( + eventSource: EventSource | null, + eventType: T, + handler: (event: Extract) => void +) { + const handlerRef = useRef(handler); + const recordEvent = useSseConnectionStore((store) => store.recordEvent); + + useEffect(() => { + handlerRef.current = handler; + }, [handler]); + + useEffect(() => { + if (!eventSource) return; + + const listener = (e: MessageEvent) => { + try { + const parsed: SseEvent = JSON.parse(e.data); + const transformed = transformEntityDates(parsed) as SseEvent; + + if (transformed.type === eventType) { + recordEvent(); + handlerRef.current(transformed as Extract); + } + } catch (error) { + console.error(`[SSE] Failed to parse ${eventType} event:`, error, e.data); + } + }; + + eventSource.addEventListener(eventType, listener); + + return () => { + eventSource.removeEventListener(eventType, listener); + }; + }, [eventSource, eventType, recordEvent]); +} From fd3d3e9fa0fe6a7052080d4f789ae529a2894888 Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Thu, 18 Dec 2025 06:52:51 -0500 Subject: [PATCH 10/19] Add SWR cache invalidation for SSE events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement automatic SWR cache revalidation when SSE events arrive. Handles all data change events (action, agreement, and overarching_goal created/updated/deleted) by triggering cache invalidation. This approach requires zero changes to existing components - SWR automatically refetches data when caches are invalidated, maintaining consistency with REST API patterns and providing automatic optimistic updates. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/lib/hooks/use-sse-cache-invalidation.ts | 60 +++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/lib/hooks/use-sse-cache-invalidation.ts diff --git a/src/lib/hooks/use-sse-cache-invalidation.ts b/src/lib/hooks/use-sse-cache-invalidation.ts new file mode 100644 index 00000000..fc4bf4d7 --- /dev/null +++ b/src/lib/hooks/use-sse-cache-invalidation.ts @@ -0,0 +1,60 @@ +"use client"; + +import { useCallback } from 'react'; +import { useSWRConfig } from 'swr'; +import { siteConfig } from '@/site.config'; +import { useSseEventHandler } from './use-sse-event-handler'; + +export function useSseCacheInvalidation(eventSource: EventSource | null) { + const { mutate } = useSWRConfig(); + const baseUrl = siteConfig.env.backendServiceURL; + + const invalidateAllCaches = useCallback(() => { + mutate((key) => typeof key === 'string' && key.includes(baseUrl)); + }, [mutate, baseUrl]); + + useSseEventHandler(eventSource, 'action_created', () => { + invalidateAllCaches(); + console.log('[SSE] Revalidated caches after action_created'); + }); + + useSseEventHandler(eventSource, 'action_updated', () => { + invalidateAllCaches(); + console.log('[SSE] Revalidated caches after action_updated'); + }); + + useSseEventHandler(eventSource, 'action_deleted', () => { + invalidateAllCaches(); + console.log('[SSE] Revalidated caches after action_deleted'); + }); + + useSseEventHandler(eventSource, 'agreement_created', () => { + invalidateAllCaches(); + console.log('[SSE] Revalidated caches after agreement_created'); + }); + + useSseEventHandler(eventSource, 'agreement_updated', () => { + invalidateAllCaches(); + console.log('[SSE] Revalidated caches after agreement_updated'); + }); + + useSseEventHandler(eventSource, 'agreement_deleted', () => { + invalidateAllCaches(); + console.log('[SSE] Revalidated caches after agreement_deleted'); + }); + + useSseEventHandler(eventSource, 'overarching_goal_created', () => { + invalidateAllCaches(); + console.log('[SSE] Revalidated caches after overarching_goal_created'); + }); + + useSseEventHandler(eventSource, 'overarching_goal_updated', () => { + invalidateAllCaches(); + console.log('[SSE] Revalidated caches after overarching_goal_updated'); + }); + + useSseEventHandler(eventSource, 'overarching_goal_deleted', () => { + invalidateAllCaches(); + console.log('[SSE] Revalidated caches after overarching_goal_deleted'); + }); +} From 0bdd55ed20e99f5d156172449d2a2f895cbf313d Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Thu, 18 Dec 2025 06:53:01 -0500 Subject: [PATCH 11/19] Add SSE system events handler for force logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement force_logout event handling by reusing the existing useLogoutUser hook. When a force_logout event is received, triggers the full logout sequence (cleanup registry execution, cache clearing, session deletion, and redirect) automatically. This ensures consistent logout behavior whether initiated by the user or by the server via SSE. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/lib/hooks/use-sse-system-events.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/lib/hooks/use-sse-system-events.ts diff --git a/src/lib/hooks/use-sse-system-events.ts b/src/lib/hooks/use-sse-system-events.ts new file mode 100644 index 00000000..3dd36197 --- /dev/null +++ b/src/lib/hooks/use-sse-system-events.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useAuthStore } from '@/lib/providers/auth-store-provider'; +import { useLogoutUser } from '@/lib/hooks/use-logout-user'; +import { useSseEventHandler } from './use-sse-event-handler'; + +export function useSseSystemEvents(eventSource: EventSource | null) { + const logout = useLogoutUser(); + const isLoggedIn = useAuthStore((store) => store.isLoggedIn); + + useSseEventHandler(eventSource, 'force_logout', async (event) => { + console.warn('[SSE] Force logout received:', event.data.reason); + + if (isLoggedIn) { + await logout(); + } + }); +} From 1fd2b7d000f141926cc68e69bfb863a92f56bf1b Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Thu, 18 Dec 2025 06:53:13 -0500 Subject: [PATCH 12/19] Add SSE provider and integrate into root providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create SseProvider component that composes all SSE hooks and manages the connection store lifecycle. Follows the same pattern as AuthStoreProvider with store creation using useRef. Integrate SseProvider into the root Providers component, nested within SWRConfig to ensure proper access to SWR's mutate functionality for cache invalidation. The provider automatically: - Establishes SSE connection when user is logged in - Invalidates SWR caches on data change events - Handles force logout system events - Cleans up connection on logout 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/components/providers.tsx | 5 ++++- src/lib/providers/sse-provider.tsx | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 src/lib/providers/sse-provider.tsx diff --git a/src/components/providers.tsx b/src/components/providers.tsx index dcc971a2..7cb23138 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -5,6 +5,7 @@ import { AuthStoreProvider } from '@/lib/providers/auth-store-provider'; import { OrganizationStateStoreProvider } from '@/lib/providers/organization-state-store-provider'; import { CoachingRelationshipStateStoreProvider } from '@/lib/providers/coaching-relationship-state-store-provider'; import { SessionCleanupProvider } from '@/lib/providers/session-cleanup-provider'; +import { SseProvider } from '@/lib/providers/sse-provider'; import { SWRConfig } from 'swr'; interface ProvidersProps { @@ -24,7 +25,9 @@ export function Providers({ children }: ProvidersProps) { provider: () => new Map(), }} > - {children} + + {children} + diff --git a/src/lib/providers/sse-provider.tsx b/src/lib/providers/sse-provider.tsx new file mode 100644 index 00000000..5bdb8c34 --- /dev/null +++ b/src/lib/providers/sse-provider.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { type ReactNode, useRef } from 'react'; +import { createSseConnectionStore } from '@/lib/stores/sse-connection-store'; +import { SseConnectionStoreContext } from '@/lib/contexts/sse-connection-context'; +import { useAuthStore } from '@/lib/providers/auth-store-provider'; +import { useSseConnection } from '@/lib/hooks/use-sse-connection'; +import { useSseCacheInvalidation } from '@/lib/hooks/use-sse-cache-invalidation'; +import { useSseSystemEvents } from '@/lib/hooks/use-sse-system-events'; + +export interface SseProviderProps { + children: ReactNode; +} + +export const SseProvider = ({ children }: SseProviderProps) => { + const storeRef = useRef(createSseConnectionStore()); + + const isLoggedIn = useAuthStore((store) => store.isLoggedIn); + const eventSource = useSseConnection(isLoggedIn); + + useSseCacheInvalidation(eventSource); + useSseSystemEvents(eventSource); + + return ( + + {children} + + ); +}; From e7801863d162c87229510441baa75206dae3816f Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Mon, 22 Dec 2025 10:21:53 -0500 Subject: [PATCH 13/19] fix(sse): handle array keys in SWR cache invalidation filter - Update mutate filter to check both string and array keys - Add explicit revalidate: true option to force immediate refetch - Handle SWR's array key format: [url, params] Previously, the filter only checked for string keys, but SWR uses array keys for parameterized requests like [url, sessionId]. This caused SSE events to trigger cache invalidation without actually revalidating any data, as no keys matched the filter. This fix ensures real-time UI updates when SSE events arrive. --- src/lib/hooks/use-sse-cache-invalidation.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/hooks/use-sse-cache-invalidation.ts b/src/lib/hooks/use-sse-cache-invalidation.ts index fc4bf4d7..c25b70e3 100644 --- a/src/lib/hooks/use-sse-cache-invalidation.ts +++ b/src/lib/hooks/use-sse-cache-invalidation.ts @@ -10,7 +10,17 @@ export function useSseCacheInvalidation(eventSource: EventSource | null) { const baseUrl = siteConfig.env.backendServiceURL; const invalidateAllCaches = useCallback(() => { - mutate((key) => typeof key === 'string' && key.includes(baseUrl)); + mutate( + (key) => { + // Handle string keys + if (typeof key === 'string' && key.includes(baseUrl)) return true; + // Handle array keys (SWR supports both formats) + if (Array.isArray(key) && key[0] && key[0].includes(baseUrl)) return true; + return false; + }, + undefined, + { revalidate: true } + ); }, [mutate, baseUrl]); useSseEventHandler(eventSource, 'action_created', () => { From 59b984824f468fa5106886fbcf5307fdb677b65d Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Mon, 22 Dec 2025 10:22:09 -0500 Subject: [PATCH 14/19] refactor(sse): improve provider initialization and type safety - Change context default from undefined to null for better type safety - Extract SSE connection manager into separate component - Use lazy initialization for store creation with useRef check - Add explicit StoreApi type annotations This improves React's hooks rules compliance and ensures the store is only created once per provider instance. --- src/lib/contexts/sse-connection-context.tsx | 2 +- src/lib/providers/sse-provider.tsx | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/lib/contexts/sse-connection-context.tsx b/src/lib/contexts/sse-connection-context.tsx index fbe55047..6c233629 100644 --- a/src/lib/contexts/sse-connection-context.tsx +++ b/src/lib/contexts/sse-connection-context.tsx @@ -5,7 +5,7 @@ import { type StoreApi, useStore } from 'zustand'; import { type SseConnectionStore } from '@/lib/stores/sse-connection-store'; import { useShallow } from 'zustand/shallow'; -export const SseConnectionStoreContext = createContext | undefined>(undefined); +export const SseConnectionStoreContext = createContext | null>(null); export const useSseConnectionStore = ( selector: (store: SseConnectionStore) => T diff --git a/src/lib/providers/sse-provider.tsx b/src/lib/providers/sse-provider.tsx index 5bdb8c34..7f96234c 100644 --- a/src/lib/providers/sse-provider.tsx +++ b/src/lib/providers/sse-provider.tsx @@ -1,7 +1,8 @@ "use client"; import { type ReactNode, useRef } from 'react'; -import { createSseConnectionStore } from '@/lib/stores/sse-connection-store'; +import { type StoreApi } from 'zustand'; +import { type SseConnectionStore, createSseConnectionStore } from '@/lib/stores/sse-connection-store'; import { SseConnectionStoreContext } from '@/lib/contexts/sse-connection-context'; import { useAuthStore } from '@/lib/providers/auth-store-provider'; import { useSseConnection } from '@/lib/hooks/use-sse-connection'; @@ -12,17 +13,26 @@ export interface SseProviderProps { children: ReactNode; } -export const SseProvider = ({ children }: SseProviderProps) => { - const storeRef = useRef(createSseConnectionStore()); - +function SseConnectionManager() { const isLoggedIn = useAuthStore((store) => store.isLoggedIn); const eventSource = useSseConnection(isLoggedIn); useSseCacheInvalidation(eventSource); useSseSystemEvents(eventSource); + return null; +} + +export const SseProvider = ({ children }: SseProviderProps) => { + const storeRef = useRef>(); + + if (!storeRef.current) { + storeRef.current = createSseConnectionStore(); + } + return ( + {children} ); From 341fd3515f00d377e5a7d57b7470cb8bfe487eba Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Wed, 7 Jan 2026 07:50:13 -0500 Subject: [PATCH 15/19] fix(sse): stabilize connection by fixing useEffect dependencies The SSE connection was repeatedly closing and reconnecting due to unstable Zustand action references in the useEffect dependency array. This caused the backend to send events to closed channels, preventing real-time updates for other users in the same session. Changes: - Remove Zustand actions from useEffect dependencies (actions are stable) - Use entire store instance instead of destructured actions - Change from useRef to useState for store initialization (idiomatic Zustand pattern) - Add ESLint disable comment with explanation This ensures the SSE connection only closes/reconnects when isLoggedIn changes, not on every render, allowing proper event delivery to all connected users. Fixes issue where User B did not see updates when User A created an Overarching Goal. --- src/lib/hooks/use-sse-connection.ts | 31 ++++++++++------------------- src/lib/providers/sse-provider.tsx | 13 ++++-------- 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/src/lib/hooks/use-sse-connection.ts b/src/lib/hooks/use-sse-connection.ts index 5e21b585..a09a759b 100644 --- a/src/lib/hooks/use-sse-connection.ts +++ b/src/lib/hooks/use-sse-connection.ts @@ -8,29 +8,18 @@ import { logoutCleanupRegistry } from '@/lib/hooks/logout-cleanup-registry'; export function useSseConnection(isLoggedIn: boolean) { const eventSourceRef = useRef(null); - const { - setConnecting, - setConnected, - setReconnecting, - setError, - setDisconnected, - } = useSseConnectionStore((store) => ({ - setConnecting: store.setConnecting, - setConnected: store.setConnected, - setReconnecting: store.setReconnecting, - setError: store.setError, - setDisconnected: store.setDisconnected, - })); + // Get store instance directly - Zustand actions are stable and don't need to be in dependencies + const store = useSseConnectionStore((state) => state); useEffect(() => { if (!isLoggedIn) { eventSourceRef.current?.close(); eventSourceRef.current = null; - setDisconnected(); + store.setDisconnected(); return; } - setConnecting(); + store.setConnecting(); const source = new EventSource(`${siteConfig.env.backendServiceURL}/sse`, { withCredentials: true, @@ -38,7 +27,7 @@ export function useSseConnection(isLoggedIn: boolean) { source.onopen = () => { console.log('[SSE] Connection established'); - setConnected(); + store.setConnected(); }; source.onerror = (error) => { @@ -47,11 +36,11 @@ export function useSseConnection(isLoggedIn: boolean) { // Check readyState to distinguish network errors from HTTP errors if (source.readyState === EventSource.CONNECTING) { // Browser is attempting reconnection (network error) - setReconnecting(); + store.setReconnecting(); console.log('[SSE] Connection lost, browser attempting reconnection...'); } else { // EventSource.CLOSED - permanent failure (HTTP error like 401, 403, 500) - setError('Connection failed - check authentication or server status'); + store.setError('Connection failed - check authentication or server status'); source.close(); } }; @@ -65,10 +54,12 @@ export function useSseConnection(isLoggedIn: boolean) { return () => { source.close(); - setDisconnected(); + store.setDisconnected(); unregisterCleanup(); }; - }, [isLoggedIn, setConnecting, setConnected, setReconnecting, setError, setDisconnected]); + // Zustand actions are stable and never change, so we only depend on isLoggedIn + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoggedIn]); return eventSourceRef.current; } diff --git a/src/lib/providers/sse-provider.tsx b/src/lib/providers/sse-provider.tsx index 7f96234c..7529e991 100644 --- a/src/lib/providers/sse-provider.tsx +++ b/src/lib/providers/sse-provider.tsx @@ -1,8 +1,7 @@ "use client"; -import { type ReactNode, useRef } from 'react'; -import { type StoreApi } from 'zustand'; -import { type SseConnectionStore, createSseConnectionStore } from '@/lib/stores/sse-connection-store'; +import { type ReactNode, useState } from 'react'; +import { createSseConnectionStore } from '@/lib/stores/sse-connection-store'; import { SseConnectionStoreContext } from '@/lib/contexts/sse-connection-context'; import { useAuthStore } from '@/lib/providers/auth-store-provider'; import { useSseConnection } from '@/lib/hooks/use-sse-connection'; @@ -24,14 +23,10 @@ function SseConnectionManager() { } export const SseProvider = ({ children }: SseProviderProps) => { - const storeRef = useRef>(); - - if (!storeRef.current) { - storeRef.current = createSseConnectionStore(); - } + const [store] = useState(() => createSseConnectionStore()); return ( - + {children} From a70b6475d364dd0e7ac2a117ed8b0c4e676c4a0b Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Wed, 7 Jan 2026 07:50:22 -0500 Subject: [PATCH 16/19] security: remove sensitive data from console logging Remove console.trace statements that were logging complete entity objects (actions, agreements, overarching goals) containing: - User IDs - Session IDs - Relationship IDs - Content data - Timestamps - Status information This data could be exposed through: - Browser extensions - Developer tools - Monitoring/logging tools - Screenshots/recordings Changes: - Remove console.trace calls in create/update/delete operations - Remove unused toString import functions - Maintain error logging for debugging (no sensitive data) --- .../ui/coaching-sessions/actions-list.tsx | 14 ++++---------- .../ui/coaching-sessions/agreements-list.tsx | 17 ++++------------- .../overarching-goal-container.tsx | 16 +++------------- 3 files changed, 11 insertions(+), 36 deletions(-) diff --git a/src/components/ui/coaching-sessions/actions-list.tsx b/src/components/ui/coaching-sessions/actions-list.tsx index 7e2dff88..21497a96 100644 --- a/src/components/ui/coaching-sessions/actions-list.tsx +++ b/src/components/ui/coaching-sessions/actions-list.tsx @@ -46,7 +46,7 @@ import { import { useActionList } from "@/lib/api/actions"; import { DateTime } from "ts-luxon"; import { siteConfig } from "@/site.config"; -import { Action, actionToString } from "@/types/action"; +import { Action } from "@/types/action"; import { cn } from "@/components/lib/utils"; import { getTableRowClasses, @@ -152,18 +152,16 @@ const ActionsList: React.FC<{ try { if (editingActionId) { // Update existing action - const action = await onActionEdited( + await onActionEdited( editingActionId, newBody, newStatus, newDueBy ); - console.trace("Updated Action: " + actionToString(action)); setEditingActionId(null); } else { // Create new action - const action = await onActionAdded(newBody, newStatus, newDueBy); - console.trace("Newly created Action: " + actionToString(action)); + await onActionAdded(newBody, newStatus, newDueBy); } // Refresh the actions list from the hook @@ -182,11 +180,7 @@ const ActionsList: React.FC<{ try { // Delete action in backend - const deletedAction = await onActionDeleted(id); - - console.trace( - "Deleted Action (onActionDeleted): " + actionToString(deletedAction) - ); + await onActionDeleted(id); // Refresh the actions list from the hook refresh(); diff --git a/src/components/ui/coaching-sessions/agreements-list.tsx b/src/components/ui/coaching-sessions/agreements-list.tsx index f6765698..0b47121f 100644 --- a/src/components/ui/coaching-sessions/agreements-list.tsx +++ b/src/components/ui/coaching-sessions/agreements-list.tsx @@ -20,7 +20,7 @@ import { import { MoreHorizontal, ArrowUp, ArrowDown } from "lucide-react"; import { Id } from "@/types/general"; import { useAgreementList } from "@/lib/api/agreements"; -import { Agreement, agreementToString } from "@/types/agreement"; +import { Agreement } from "@/types/agreement"; import { DateTime } from "ts-luxon"; import { siteConfig } from "@/site.config"; import { cn } from "@/components/lib/utils"; @@ -86,18 +86,14 @@ const AgreementsList: React.FC<{ try { if (editingAgreementId) { // Update existing agreement - const agreement = await onAgreementEdited( + await onAgreementEdited( editingAgreementId, newAgreement ); - console.trace("Updated Agreement: " + agreementToString(agreement)); setEditingAgreementId(null); } else { // Create new agreement - const agreement = await onAgreementAdded(newAgreement); - console.trace( - "Newly created Agreement: " + agreementToString(agreement) - ); + await onAgreementAdded(newAgreement); } // Refresh the agreements list from the hook @@ -116,12 +112,7 @@ const AgreementsList: React.FC<{ try { // Delete agreement in backend - const deletedAgreement = await onAgreementDeleted(id); - - console.trace( - "Deleted Agreement (onAgreementDeleted): " + - agreementToString(deletedAgreement) - ); + await onAgreementDeleted(id); // Refresh the agreements list from the hook refresh(); diff --git a/src/components/ui/coaching-sessions/overarching-goal-container.tsx b/src/components/ui/coaching-sessions/overarching-goal-container.tsx index 3ae65494..5f26359d 100644 --- a/src/components/ui/coaching-sessions/overarching-goal-container.tsx +++ b/src/components/ui/coaching-sessions/overarching-goal-container.tsx @@ -10,10 +10,7 @@ import { useOverarchingGoalBySession, useOverarchingGoalMutation, } from "@/lib/api/overarching-goals"; -import { - OverarchingGoal, - overarchingGoalToString, -} from "@/types/overarching-goal"; +import { OverarchingGoal } from "@/types/overarching-goal"; import { useCurrentCoachingSession } from "@/lib/hooks/use-current-coaching-session"; const OverarchingGoalContainer: React.FC<{ @@ -33,20 +30,13 @@ const OverarchingGoalContainer: React.FC<{ try { if (currentCoachingSessionId) { if (overarchingGoal.id) { - const responseGoal = await updateOverarchingGoal( + await updateOverarchingGoal( overarchingGoal.id, newGoal ); - console.trace( - "Updated Overarching Goal: " + overarchingGoalToString(responseGoal) - ); } else if (!overarchingGoal.id) { newGoal.coaching_session_id = currentCoachingSessionId; - const responseGoal = await createOverarchingGoal(newGoal); - console.trace( - "Newly created Overarching Goal: " + - overarchingGoalToString(responseGoal) - ); + await createOverarchingGoal(newGoal); // Manually trigger a local refresh of the cached OverarchingGoal data such that // any other local code using the KeyedMutator will also update with this new data. From e80df581f51e8d556262b2cb25f93cb90322ad59 Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Thu, 8 Jan 2026 13:03:53 -0500 Subject: [PATCH 17/19] test: add EventSource polyfill for jsdom environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jsdom doesn't provide a native EventSource implementation, causing "EventSource is not defined" errors in tests that use SSE functionality. Add eventsourcemock polyfill (already in devDependencies) to provide EventSource globally in the test environment using Object.defineProperty as recommended by the library documentation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/test-utils/setup.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/test-utils/setup.ts b/src/test-utils/setup.ts index e36f2edb..3866a770 100644 --- a/src/test-utils/setup.ts +++ b/src/test-utils/setup.ts @@ -1,6 +1,13 @@ import '@testing-library/jest-dom' import { vi, beforeAll, afterEach, afterAll } from 'vitest' import { server } from './msw-server' +// @ts-ignore - eventsourcemock doesn't have types, but it's only used in tests +import EventSource from 'eventsourcemock' + +// Polyfill EventSource for tests (jsdom doesn't have native EventSource) +Object.defineProperty(global, 'EventSource', { + value: EventSource, +}) // Mock Next.js router hooks to avoid app router dependency in tests vi.mock('next/navigation', () => ({ From b8a6abe2a46f89b3ce4e75d683716ace450caf78 Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Thu, 8 Jan 2026 13:04:14 -0500 Subject: [PATCH 18/19] test: mock SseProvider to prevent infinite loops in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After adding EventSource polyfill, tests were creating real SSE connections via SseProvider, which caused infinite loops during component cleanup (setDisconnected triggering re-renders). Mock SseProvider in test setup to prevent actual SSE connection establishment during tests, similar to SessionCleanupProvider. Fixes "Maximum update depth exceeded" errors in coaching session page tests by ensuring SSE hooks are not executed in test environment. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/test-utils/setup.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/test-utils/setup.ts b/src/test-utils/setup.ts index 3866a770..3ed0b8ed 100644 --- a/src/test-utils/setup.ts +++ b/src/test-utils/setup.ts @@ -34,6 +34,11 @@ vi.mock('@/lib/providers/session-cleanup-provider', () => ({ SessionCleanupProvider: ({ children }: { children: React.ReactNode }) => children })) +// Mock SseProvider to prevent actual SSE connections in tests +vi.mock('@/lib/providers/sse-provider', () => ({ + SseProvider: ({ children }: { children: React.ReactNode }) => children +})) + // Setup MSW beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) From 30dedcdef3d6b8d07f84493aa0b105ee908864d4 Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Thu, 8 Jan 2026 13:08:52 -0500 Subject: [PATCH 19/19] build: add eventsourcemock dev dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add eventsourcemock package for providing EventSource polyfill in jsdom test environment. This package is already being used in test setup to fix "EventSource is not defined" errors when running tests that interact with SSE functionality. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package-lock.json | 8 ++++++++ package.json | 1 + 2 files changed, 9 insertions(+) diff --git a/package-lock.json b/package-lock.json index eccbc109..cb372382 100644 --- a/package-lock.json +++ b/package-lock.json @@ -88,6 +88,7 @@ "autoprefixer": "^10.4.20", "eslint": "^8.57.1", "eslint-config-next": "15.4.8", + "eventsourcemock": "^2.0.0", "jsdom": "^26.1.0", "msw": "^2.10.3", "postcss": "^8.4.49", @@ -7617,6 +7618,13 @@ "node": ">=0.10.0" } }, + "node_modules/eventsourcemock": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eventsourcemock/-/eventsourcemock-2.0.0.tgz", + "integrity": "sha512-tSmJnuE+h6A8/hLRg0usf1yL+Q8w01RQtmg0Uzgoxk/HIPZrIUeAr/A4es/8h1wNsoG8RdiESNQLTKiNwbSC3Q==", + "dev": true, + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", diff --git a/package.json b/package.json index 14f27003..c052df84 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "autoprefixer": "^10.4.20", "eslint": "^8.57.1", "eslint-config-next": "15.4.8", + "eventsourcemock": "^2.0.0", "jsdom": "^26.1.0", "msw": "^2.10.3", "postcss": "^8.4.49",