diff --git a/frontend/src/lib/hooks/useProjectEvents.ts b/frontend/src/lib/hooks/useProjectEvents.ts index ac4494f..5f2cd15 100644 --- a/frontend/src/lib/hooks/useProjectEvents.ts +++ b/frontend/src/lib/hooks/useProjectEvents.ts @@ -130,6 +130,8 @@ export function useProjectEvents( const isManualDisconnectRef = useRef(false); const mountedRef = useRef(true); const pingHandlerRef = useRef<(() => void) | null>(null); + // Ref to hold latest connect function to avoid stale closure in scheduleReconnect + const connectRef = useRef<(() => void) | null>(null); /** * Update connection state and notify callback @@ -206,11 +208,13 @@ export function useProjectEvents( /** * Calculate next retry delay with exponential backoff + * Returns current delay, then increases for next call */ const getNextRetryDelay = useCallback(() => { - const nextDelay = currentRetryDelayRef.current * BACKOFF_MULTIPLIER; - currentRetryDelayRef.current = Math.min(nextDelay, maxRetryDelay); - return currentRetryDelayRef.current; + const delay = currentRetryDelayRef.current; + // Increase for next call (capped at max) + currentRetryDelayRef.current = Math.min(delay * BACKOFF_MULTIPLIER, maxRetryDelay); + return delay; }, [maxRetryDelay]); /** @@ -234,7 +238,8 @@ export function useProjectEvents( retryTimeoutRef.current = setTimeout(() => { if (!mountedRef.current || isManualDisconnectRef.current) return; setRetryCount((prev) => prev + 1); - connect(); + // Use ref to always call latest connect function (avoids stale closure) + connectRef.current?.(); }, delay); }, [retryCount, maxRetryAttempts, getNextRetryDelay, updateConnectionState]); @@ -344,6 +349,9 @@ export function useProjectEvents( initialRetryDelay, ]); + // Keep ref updated with latest connect function for scheduleReconnect + connectRef.current = connect; + /** * Manually disconnect from SSE */ @@ -371,11 +379,22 @@ export function useProjectEvents( clearProjectEvents(projectId); }, [clearProjectEvents, projectId]); - // Consolidated connection management effect - // Handles both initial mount and auth state changes to prevent race conditions + // Effect 1: Unmount cleanup only + // Separated from connection management to prevent cleanup running on every state change + // This fixes: when connectionState changes, the old effect cleanup would close EventSource useEffect(() => { mountedRef.current = true; + return () => { + mountedRef.current = false; + cleanup(); + }; + }, [cleanup]); + + // Effect 2: Connection management + // No cleanup here - only Effect 1 handles cleanup on unmount + // This prevents: cleanup closing EventSource immediately after connect() creates it + useEffect(() => { // Connect when authenticated with a project and not manually disconnected if (autoConnect && isAuthenticated && accessToken && projectId) { if (connectionState === 'disconnected' && !isManualDisconnectRef.current) { @@ -385,21 +404,7 @@ export function useProjectEvents( // Disconnect when auth is lost disconnect(); } - - return () => { - mountedRef.current = false; - cleanup(); - }; - }, [ - autoConnect, - isAuthenticated, - accessToken, - projectId, - connectionState, - connect, - disconnect, - cleanup, - ]); + }, [autoConnect, isAuthenticated, accessToken, projectId, connectionState, connect, disconnect]); return { events, diff --git a/frontend/src/lib/stores/authStore.ts b/frontend/src/lib/stores/authStore.ts index 7f9579c..4d347c4 100644 --- a/frontend/src/lib/stores/authStore.ts +++ b/frontend/src/lib/stores/authStore.ts @@ -62,8 +62,8 @@ function isValidToken(token: string): boolean { * @returns Unix timestamp */ function calculateExpiry(expiresIn?: number): number { - // Default to 15 minutes if not provided or invalid - let seconds = expiresIn || 900; + // Use nullish coalescing - 0 is invalid but should trigger warning, not silent fallback + let seconds = expiresIn ?? 900; // Validate positive number and prevent overflow if (seconds <= 0 || seconds > 31536000) {