Security and Code Quality Remediation (M6-Fixes) #343
@@ -215,6 +215,140 @@ describe("useWebSocket", (): void => {
|
||||
expect(io).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe("stale closure prevention", (): void => {
|
||||
it("should NOT disconnect when callback functions change", (): void => {
|
||||
const onTaskCreated1 = vi.fn();
|
||||
const onTaskCreated2 = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ onTaskCreated }: { onTaskCreated: (task: { id: string }) => void }) =>
|
||||
useWebSocket("workspace-123", "token", { onTaskCreated }),
|
||||
{ initialProps: { onTaskCreated: onTaskCreated1 } }
|
||||
);
|
||||
|
||||
expect(io).toHaveBeenCalledTimes(1);
|
||||
expect(mockSocket.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
// Change the callback - this should NOT cause a reconnect
|
||||
rerender({ onTaskCreated: onTaskCreated2 });
|
||||
|
||||
// Socket should NOT have been disconnected or reconnected
|
||||
expect(mockSocket.disconnect).not.toHaveBeenCalled();
|
||||
expect(io).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should use the latest callback after callback change", async (): Promise<void> => {
|
||||
const onTaskCreated1 = vi.fn();
|
||||
const onTaskCreated2 = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ onTaskCreated }: { onTaskCreated: (task: { id: string }) => void }) =>
|
||||
useWebSocket("workspace-123", "token", { onTaskCreated }),
|
||||
{ initialProps: { onTaskCreated: onTaskCreated1 } }
|
||||
);
|
||||
|
||||
// Emit event with first callback
|
||||
const task1 = { id: "task-1" };
|
||||
act(() => {
|
||||
eventHandlers["task:created"]?.(task1);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onTaskCreated1).toHaveBeenCalledWith(task1);
|
||||
expect(onTaskCreated2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Update to new callback
|
||||
rerender({ onTaskCreated: onTaskCreated2 });
|
||||
|
||||
// Emit another event - should use the new callback
|
||||
const task2 = { id: "task-2" };
|
||||
act(() => {
|
||||
eventHandlers["task:created"]?.(task2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onTaskCreated2).toHaveBeenCalledWith(task2);
|
||||
// First callback should only have been called once (with task1)
|
||||
expect(onTaskCreated1).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT disconnect when multiple callbacks change simultaneously", (): void => {
|
||||
const callbacks1 = {
|
||||
onTaskCreated: vi.fn(),
|
||||
onTaskUpdated: vi.fn(),
|
||||
onEventCreated: vi.fn(),
|
||||
};
|
||||
const callbacks2 = {
|
||||
onTaskCreated: vi.fn(),
|
||||
onTaskUpdated: vi.fn(),
|
||||
onEventCreated: vi.fn(),
|
||||
};
|
||||
|
||||
interface CallbackProps {
|
||||
onTaskCreated: (task: { id: string }) => void;
|
||||
onTaskUpdated: (task: { id: string }) => void;
|
||||
onEventCreated: (event: { id: string }) => void;
|
||||
}
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: CallbackProps) => useWebSocket("workspace-123", "token", props),
|
||||
{ initialProps: callbacks1 }
|
||||
);
|
||||
|
||||
expect(io).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change all callbacks at once - should NOT cause reconnect
|
||||
rerender(callbacks2);
|
||||
|
||||
expect(mockSocket.disconnect).not.toHaveBeenCalled();
|
||||
expect(io).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle callback being removed without reconnect", (): void => {
|
||||
const onTaskCreated = vi.fn();
|
||||
|
||||
interface CallbackProps {
|
||||
onTaskCreated?: (task: { id: string }) => void;
|
||||
}
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: CallbackProps) => useWebSocket("workspace-123", "token", props),
|
||||
{ initialProps: { onTaskCreated } as CallbackProps }
|
||||
);
|
||||
|
||||
expect(io).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Remove callback - should NOT cause reconnect
|
||||
rerender({});
|
||||
|
||||
expect(mockSocket.disconnect).not.toHaveBeenCalled();
|
||||
expect(io).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle callback being added without reconnect", (): void => {
|
||||
const onTaskCreated = vi.fn();
|
||||
|
||||
interface CallbackProps {
|
||||
onTaskCreated?: (task: { id: string }) => void;
|
||||
}
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: CallbackProps) => useWebSocket("workspace-123", "token", props),
|
||||
{ initialProps: {} as CallbackProps }
|
||||
);
|
||||
|
||||
expect(io).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Add callback - should NOT cause reconnect
|
||||
rerender({ onTaskCreated });
|
||||
|
||||
expect(mockSocket.disconnect).not.toHaveBeenCalled();
|
||||
expect(io).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should clean up all event listeners on unmount", (): void => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useWebSocket("workspace-123", "token", {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user