From 934decd314b71fe45c2e11a43e0e3e7cdb404c4c Mon Sep 17 00:00:00 2001 From: beabigegg Date: Thu, 8 Jan 2026 22:49:19 +0800 Subject: [PATCH] fix: resolve WebSocket connection issues and API errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix React StrictMode double-mount causing WebSocket connection loops - Add isMountedRef to Tasks.tsx and NotificationContext.tsx - Delay WebSocket connection by 100ms to avoid race conditions - Check mounted state before reconnection attempts - Fix React Router v7 deprecation warnings - Add future flags: v7_startTransition, v7_relativeSplatPath - Fix CalendarView 422 API error - Send full ISO 8601 datetime format instead of date-only - Add URL encoding for query parameters 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/components/CalendarView.tsx | 8 ++-- frontend/src/contexts/NotificationContext.tsx | 47 +++++++++++++------ frontend/src/contexts/ProjectSyncContext.tsx | 4 +- frontend/src/main.tsx | 2 +- frontend/src/pages/Tasks.tsx | 20 ++++++-- 5 files changed, 55 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/CalendarView.tsx b/frontend/src/components/CalendarView.tsx index f908917..99635c7 100644 --- a/frontend/src/components/CalendarView.tsx +++ b/frontend/src/components/CalendarView.tsx @@ -119,12 +119,12 @@ export function CalendarView({ const loadTasks = async (start: Date, end: Date) => { setLoading(true) try { - // Format dates for API - const dueAfter = start.toISOString().split('T')[0] - const dueBefore = end.toISOString().split('T')[0] + // Format dates for API (backend expects ISO 8601 datetime) + const dueAfter = start.toISOString() + const dueBefore = end.toISOString() const response = await api.get( - `/projects/${projectId}/tasks?due_after=${dueAfter}&due_before=${dueBefore}` + `/projects/${projectId}/tasks?due_after=${encodeURIComponent(dueAfter)}&due_before=${encodeURIComponent(dueBefore)}` ) const tasks: Task[] = response.data.tasks diff --git a/frontend/src/contexts/NotificationContext.tsx b/frontend/src/contexts/NotificationContext.tsx index ee2d2b5..9e242a5 100644 --- a/frontend/src/contexts/NotificationContext.tsx +++ b/frontend/src/contexts/NotificationContext.tsx @@ -25,6 +25,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) { const wsRef = useRef(null) const pingIntervalRef = useRef | null>(null) const reconnectTimeoutRef = useRef | null>(null) + const isMountedRef = useRef(true) const refreshUnreadCount = useCallback(async () => { try { @@ -168,10 +169,14 @@ export function NotificationProvider({ children }: { children: ReactNode }) { pingIntervalRef.current = null } - // Attempt to reconnect after delay - reconnectTimeoutRef.current = setTimeout(() => { - connectWebSocket() - }, WS_RECONNECT_DELAY) + // Only attempt to reconnect if component is still mounted + if (isMountedRef.current) { + reconnectTimeoutRef.current = setTimeout(() => { + if (isMountedRef.current) { + connectWebSocket() + } + }, WS_RECONNECT_DELAY) + } } ws.onerror = (err) => { @@ -184,23 +189,35 @@ export function NotificationProvider({ children }: { children: ReactNode }) { // Initial fetch and WebSocket connection useEffect(() => { + isMountedRef.current = true const token = localStorage.getItem('token') if (token) { refreshUnreadCount() - connectWebSocket() + // Delay WebSocket connection to avoid StrictMode race condition + const connectTimeout = setTimeout(() => { + if (isMountedRef.current) { + connectWebSocket() + } + }, 100) + + return () => { + clearTimeout(connectTimeout) + isMountedRef.current = false + // Cleanup on unmount + if (wsRef.current) { + wsRef.current.close() + } + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + } } return () => { - // Cleanup on unmount - if (wsRef.current) { - wsRef.current.close() - } - if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current) - } - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) - } + isMountedRef.current = false } }, [refreshUnreadCount, connectWebSocket]) diff --git a/frontend/src/contexts/ProjectSyncContext.tsx b/frontend/src/contexts/ProjectSyncContext.tsx index 22c4291..7aaf71e 100644 --- a/frontend/src/contexts/ProjectSyncContext.tsx +++ b/frontend/src/contexts/ProjectSyncContext.tsx @@ -1,5 +1,4 @@ import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react' -import { useAuth } from './AuthContext' interface TaskEvent { type: 'task_created' | 'task_updated' | 'task_status_changed' | 'task_deleted' | 'task_assigned' @@ -62,7 +61,6 @@ const devError = (...args: unknown[]) => { } export function ProjectSyncProvider({ children }: { children: React.ReactNode }) { - const { user } = useAuth() const [isConnected, setIsConnected] = useState(false) const [currentProjectId, setCurrentProjectId] = useState(null) const wsRef = useRef(null) @@ -212,7 +210,7 @@ export function ProjectSyncProvider({ children }: { children: React.ReactNode }) } catch (err) { devError('Failed to create WebSocket:', err) } - }, [user?.id, cleanup]) + }, [cleanup]) const unsubscribeFromProject = useCallback(() => { targetProjectIdRef.current = null diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 4f87035..c14ac13 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -10,7 +10,7 @@ import './index.css' ReactDOM.createRoot(document.getElementById('root')!).render( - + diff --git a/frontend/src/pages/Tasks.tsx b/frontend/src/pages/Tasks.tsx index a2ccd66..f08cd0b 100644 --- a/frontend/src/pages/Tasks.tsx +++ b/frontend/src/pages/Tasks.tsx @@ -122,14 +122,28 @@ export default function Tasks() { } // Subscribe to project WebSocket when project changes + // Use isMounted ref to handle React StrictMode's double-mount behavior + const isMountedRef = useRef(true) useEffect(() => { + isMountedRef.current = true if (projectId) { - subscribeToProject(projectId) + // Small delay to avoid race conditions with StrictMode's rapid mount/unmount + const timeoutId = setTimeout(() => { + if (isMountedRef.current) { + subscribeToProject(projectId) + } + }, 100) + return () => { + clearTimeout(timeoutId) + isMountedRef.current = false + unsubscribeFromProject() + } } return () => { - unsubscribeFromProject() + isMountedRef.current = false } - }, [projectId, subscribeToProject, unsubscribeFromProject]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId]) // Handle real-time task events from WebSocket const handleTaskEvent = useCallback((event: TaskEvent) => {