fix(#338): Fix useWebSocket stale closure by using refs for callbacks

- Use useRef to store callbacks, preventing stale closures
- Remove callback functions from useEffect dependencies
- Only workspaceId and token trigger reconnects now
- Callback changes update the ref without causing reconnects
- Add 5 new tests verifying no reconnect on callback changes

Refs #338

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-05 18:58:35 -06:00
parent 880919c77e
commit dcf9a2217d
2 changed files with 190 additions and 50 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { Socket } from "socket.io-client";
import { io } from "socket.io-client";
import { API_BASE_URL } from "@/lib/config";
@@ -77,15 +77,14 @@ export function useWebSocket(
const [isConnected, setIsConnected] = useState<boolean>(false);
const [connectionError, setConnectionError] = useState<ConnectionError | null>(null);
const {
onTaskCreated,
onTaskUpdated,
onTaskDeleted,
onEventCreated,
onEventUpdated,
onEventDeleted,
onProjectUpdated,
} = callbacks;
// Use refs for callbacks to prevent stale closures and unnecessary reconnects
// This ensures that callback changes don't trigger useEffect re-runs
const callbacksRef = useRef<WebSocketCallbacks>(callbacks);
// Keep the ref up-to-date with the latest callbacks
useEffect(() => {
callbacksRef.current = callbacks;
}, [callbacks]);
useEffect(() => {
// Use WebSocket URL from central config
@@ -123,32 +122,48 @@ export function useWebSocket(
setIsConnected(false);
};
// Wrapper functions that always use the latest callbacks via ref
// This prevents stale closure issues while avoiding reconnects on callback changes
const handleTaskCreated = (task: Task): void => {
callbacksRef.current.onTaskCreated?.(task);
};
const handleTaskUpdated = (task: Task): void => {
callbacksRef.current.onTaskUpdated?.(task);
};
const handleTaskDeleted = (payload: DeletePayload): void => {
callbacksRef.current.onTaskDeleted?.(payload);
};
const handleEventCreated = (event: Event): void => {
callbacksRef.current.onEventCreated?.(event);
};
const handleEventUpdated = (event: Event): void => {
callbacksRef.current.onEventUpdated?.(event);
};
const handleEventDeleted = (payload: DeletePayload): void => {
callbacksRef.current.onEventDeleted?.(payload);
};
const handleProjectUpdated = (project: Project): void => {
callbacksRef.current.onProjectUpdated?.(project);
};
newSocket.on("connect", handleConnect);
newSocket.on("disconnect", handleDisconnect);
newSocket.on("connect_error", handleConnectError);
// Real-time event handlers
if (onTaskCreated) {
newSocket.on("task:created", onTaskCreated);
}
if (onTaskUpdated) {
newSocket.on("task:updated", onTaskUpdated);
}
if (onTaskDeleted) {
newSocket.on("task:deleted", onTaskDeleted);
}
if (onEventCreated) {
newSocket.on("event:created", onEventCreated);
}
if (onEventUpdated) {
newSocket.on("event:updated", onEventUpdated);
}
if (onEventDeleted) {
newSocket.on("event:deleted", onEventDeleted);
}
if (onProjectUpdated) {
newSocket.on("project:updated", onProjectUpdated);
}
// Register all event handlers - they'll check the ref for actual callbacks
newSocket.on("task:created", handleTaskCreated);
newSocket.on("task:updated", handleTaskUpdated);
newSocket.on("task:deleted", handleTaskDeleted);
newSocket.on("event:created", handleEventCreated);
newSocket.on("event:updated", handleEventUpdated);
newSocket.on("event:deleted", handleEventDeleted);
newSocket.on("project:updated", handleProjectUpdated);
// Cleanup on unmount or dependency change
return (): void => {
@@ -156,27 +171,18 @@ export function useWebSocket(
newSocket.off("disconnect", handleDisconnect);
newSocket.off("connect_error", handleConnectError);
if (onTaskCreated) newSocket.off("task:created", onTaskCreated);
if (onTaskUpdated) newSocket.off("task:updated", onTaskUpdated);
if (onTaskDeleted) newSocket.off("task:deleted", onTaskDeleted);
if (onEventCreated) newSocket.off("event:created", onEventCreated);
if (onEventUpdated) newSocket.off("event:updated", onEventUpdated);
if (onEventDeleted) newSocket.off("event:deleted", onEventDeleted);
if (onProjectUpdated) newSocket.off("project:updated", onProjectUpdated);
newSocket.off("task:created", handleTaskCreated);
newSocket.off("task:updated", handleTaskUpdated);
newSocket.off("task:deleted", handleTaskDeleted);
newSocket.off("event:created", handleEventCreated);
newSocket.off("event:updated", handleEventUpdated);
newSocket.off("event:deleted", handleEventDeleted);
newSocket.off("project:updated", handleProjectUpdated);
newSocket.disconnect();
};
}, [
workspaceId,
token,
onTaskCreated,
onTaskUpdated,
onTaskDeleted,
onEventCreated,
onEventUpdated,
onEventDeleted,
onProjectUpdated,
]);
// Only stable values in deps - callbacks are accessed via ref
}, [workspaceId, token]);
return {
isConnected,