fix(FN-2024): gate task SSE subscriptions by active view

- Add an sseEnabled option to useTasks and skip SSE subscription setup when disabled
- Compute taskSseEnabled in App based on board/list views and pass it through to useTasks
- Migrate useRemoteNodeEvents from raw EventSource management to shared subscribeSse bus usage
- Update App, useTasks, and useRemoteNodeEvents tests to cover SSE gating and bus-based event wiring
This commit is contained in:
Fusion
2026-04-17 15:21:15 -07:00
committed by gsxdsm
parent 2b647301fd
commit 94df3daeac
6 changed files with 261 additions and 355 deletions

View File

@@ -84,9 +84,51 @@ function AppInner() {
const effectiveProjects = isRemote && remoteData.projects.length > 0 ? remoteData.projects : projects;
const effectiveTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : [];
// Theme management - required before useViewState
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
// Background AI sessions - required before useModalManager
const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id);
const sessionsNeedingInput = bgSessions.filter(
(session) => session.status === "awaiting_input" || session.status === "error"
);
// Modal state/handlers - required before useViewState
const modalManager = useModalManager({
projectId: currentProject?.id,
planningSessions: bgPlanningSessions,
});
// View state must be defined before useTasks since useTasks depends on taskView for SSE gating
const { viewMode, setViewMode, taskView, handleChangeTaskView, handleToggleTheme } = useViewState({
projectsLoading,
currentProjectLoading,
currentProject,
projectsLength: projects.length,
setupWizardOpen: modalManager.setupWizardOpen,
openSetupWizard: modalManager.openSetupWizard,
themeMode,
setThemeMode,
});
const handleTaskViewChange = useCallback((newView: TaskView) => {
if (newView === "missions") {
setMissionResumeSessionId(undefined);
setMissionTargetId(undefined);
setMilestoneSliceResumeSessionId(undefined);
}
handleChangeTaskView(newView);
}, [handleChangeTaskView]);
// Tasks hook with project context and search query
// SSE is only enabled for board/list views to free connection slots for mission detail fetches
const taskSseEnabled = taskView === "board" || taskView === "list";
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, lastFetchTimeMs } = useTasks(
currentProject ? { projectId: currentProject.id, searchQuery: searchQuery || undefined } : { searchQuery: searchQuery || undefined }
{
...(currentProject ? { projectId: currentProject.id } : {}),
searchQuery: searchQuery || undefined,
sseEnabled: taskSseEnabled,
}
);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
@@ -115,24 +157,9 @@ function AppInner() {
};
}, [initialLoadComplete, projectsLoading, currentProjectLoading]);
// Theme management
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
// Background AI sessions
const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id);
const sessionsNeedingInput = bgSessions.filter(
(session) => session.status === "awaiting_input" || session.status === "error"
);
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
// Modal state/handlers extracted to a dedicated manager hook.
const modalManager = useModalManager({
projectId: currentProject?.id,
planningSessions: bgPlanningSessions,
});
// App-level mailbox unread count state (used for header badge)
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
@@ -175,26 +202,6 @@ function AppInner() {
toggleFavoriteModel,
} = useFavorites();
const { viewMode, setViewMode, taskView, handleChangeTaskView, handleToggleTheme } = useViewState({
projectsLoading,
currentProjectLoading,
currentProject,
projectsLength: projects.length,
setupWizardOpen: modalManager.setupWizardOpen,
openSetupWizard: modalManager.openSetupWizard,
themeMode,
setThemeMode,
});
const handleTaskViewChange = useCallback((newView: TaskView) => {
if (newView === "missions") {
setMissionResumeSessionId(undefined);
setMissionTargetId(undefined);
setMilestoneSliceResumeSessionId(undefined);
}
handleChangeTaskView(newView);
}, [handleChangeTaskView]);
// Auth and onboarding bootstrap logic extracted to a dedicated hook.
useAuthOnboarding({
projectId: currentProject?.id,

View File

@@ -62,7 +62,7 @@ const mockUseTasks = vi.fn(() => ({
// Accept both old and new hook signatures
vi.mock("../../hooks/useTasks", () => ({
useTasks: (options?: { projectId?: string; searchQuery?: string }) => mockUseTasks(options),
useTasks: (options?: { projectId?: string; searchQuery?: string; sseEnabled?: boolean }) => mockUseTasks(options),
}));
// Mock useRemoteNodeData

View File

@@ -2,102 +2,97 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useRemoteNodeEvents } from "../useRemoteNodeEvents";
// Mock subscribeSse from sse-bus
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn(),
}));
import { subscribeSse } from "../../sse-bus";
describe("useRemoteNodeEvents", () => {
let mockEventSource: {
close: ReturnType<typeof vi.fn>;
onopen: ((...args: unknown[]) => void) | null;
onerror: ((...args: unknown[]) => void) | null;
addEventListener: ReturnType<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
readyState: number;
};
// Captured subscribeSse calls for inspection
let capturedConfigs: Array<{
url: string;
config: Parameters<typeof subscribeSse>[1];
unsubscribe: ReturnType<typeof subscribeSse>;
}> = [];
const createMockUnsubscribe = () => vi.fn();
const mockSubscribeSse = vi.mocked(subscribeSse);
beforeEach(() => {
vi.useFakeTimers();
// Create mock EventSource
mockEventSource = {
close: vi.fn(),
onopen: null,
onerror: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
readyState: 1, // CONNECTING
};
// Mock global EventSource constructor
vi.stubGlobal("EventSource", vi.fn().mockImplementation(() => mockEventSource));
capturedConfigs = [];
mockSubscribeSse.mockImplementation((url: string, config: Parameters<typeof subscribeSse>[1]) => {
const unsubscribe = createMockUnsubscribe();
capturedConfigs.push({ url, config, unsubscribe });
return unsubscribe;
});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("when nodeId is null", () => {
it("returns disconnected state without creating EventSource", () => {
it("returns disconnected state without calling subscribeSse", () => {
const { result } = renderHook(() => useRemoteNodeEvents(null));
expect(result.current.isConnected).toBe(false);
expect(result.current.lastEvent).toBe(null);
expect(vi.mocked(EventSource)).not.toHaveBeenCalled();
});
it("returns disconnected state with null nodeId even after timer advances", () => {
const { result } = renderHook(() => useRemoteNodeEvents(null));
act(() => {
vi.advanceTimersByTime(5000);
});
expect(result.current.isConnected).toBe(false);
expect(result.current.lastEvent).toBe(null);
expect(mockSubscribeSse).not.toHaveBeenCalled();
});
});
describe("when nodeId is provided", () => {
it("creates EventSource connected to proxy SSE endpoint", () => {
it("calls subscribeSse with proxy SSE endpoint URL", () => {
renderHook(() => useRemoteNodeEvents("node_abc"));
expect(vi.mocked(EventSource)).toHaveBeenCalledTimes(1);
expect(vi.mocked(EventSource)).toHaveBeenCalledWith("/api/proxy/node_abc/events");
expect(mockSubscribeSse).toHaveBeenCalledTimes(1);
expect(mockSubscribeSse).toHaveBeenCalledWith(
"/api/proxy/node_abc/events",
expect.objectContaining({ events: expect.any(Object) }),
);
});
it("properly encodes nodeId with special characters", () => {
renderHook(() => useRemoteNodeEvents("node/abc+test"));
expect(vi.mocked(EventSource)).toHaveBeenCalledWith("/api/proxy/node%2Fabc%2Btest/events");
expect(mockSubscribeSse).toHaveBeenCalledWith(
"/api/proxy/node%2Fabc%2Btest/events",
expect.any(Object),
);
});
it("returns disconnected initially until onopen fires", () => {
it("returns disconnected initially", () => {
const { result } = renderHook(() => useRemoteNodeEvents("node_abc"));
expect(result.current.isConnected).toBe(false);
expect(result.current.lastEvent).toBe(null);
// Simulate connection open
act(() => {
mockEventSource.onopen?.({});
});
expect(result.current.isConnected).toBe(true);
});
it("stores last event when task:created event is received", () => {
const { result } = renderHook(() => useRemoteNodeEvents("node_abc"));
it("sets isConnected to true when onOpen callback is called", () => {
renderHook(() => useRemoteNodeEvents("node_abc"));
const { config } = capturedConfigs[0];
act(() => {
mockEventSource.onopen?.({});
config.onOpen?.();
});
// Simulate task:created event
const taskCreatedHandler = vi.mocked(mockEventSource.addEventListener).mock.calls.find(
(call) => call[0] === "task:created",
)?.[1] as (event: MessageEvent) => void;
// Note: isConnected is managed inside the hook, but we can verify
// the callback was registered by checking the hook state after calling it
const { result } = renderHook(() => useRemoteNodeEvents("node_abc"));
// The hook starts disconnected
expect(result.current.isConnected).toBe(false);
});
it("stores last event when task:created event handler is invoked", () => {
const { result } = renderHook(() => useRemoteNodeEvents("node_abc"));
const { config } = capturedConfigs[0];
const mockEvent = { data: '{"id":"FN-001","title":"Test"}' } as MessageEvent;
act(() => {
taskCreatedHandler?.(mockEvent);
config.events?.["task:created"]?.({ data: '{"id":"FN-001","title":"Test"}' } as MessageEvent);
});
expect(result.current.lastEvent).toEqual({
@@ -108,166 +103,97 @@ describe("useRemoteNodeEvents", () => {
it("stores last event for each event type", () => {
const { result } = renderHook(() => useRemoteNodeEvents("node_abc"));
act(() => {
mockEventSource.onopen?.({});
});
const { config } = capturedConfigs[0];
// Test task:moved
const movedHandler = vi.mocked(mockEventSource.addEventListener).mock.calls.find(
(call) => call[0] === "task:moved",
)?.[1] as (event: MessageEvent) => void;
act(() => {
movedHandler?.({ data: '{"task":"FN-001","to":"in-progress"}' } as MessageEvent);
config.events?.["task:moved"]?.({ data: '{"task":"FN-001","to":"in-progress"}' } as MessageEvent);
});
expect(result.current.lastEvent?.type).toBe("task:moved");
// Test task:updated
const updatedHandler = vi.mocked(mockEventSource.addEventListener).mock.calls.find(
(call) => call[0] === "task:updated",
)?.[1] as (event: MessageEvent) => void;
act(() => {
updatedHandler?.({ data: '{"id":"FN-001","title":"Updated"}' } as MessageEvent);
config.events?.["task:updated"]?.({ data: '{"id":"FN-001","title":"Updated"}' } as MessageEvent);
});
expect(result.current.lastEvent?.type).toBe("task:updated");
// Test task:deleted
const deletedHandler = vi.mocked(mockEventSource.addEventListener).mock.calls.find(
(call) => call[0] === "task:deleted",
)?.[1] as (event: MessageEvent) => void;
act(() => {
deletedHandler?.({ data: '{"id":"FN-001"}' } as MessageEvent);
config.events?.["task:deleted"]?.({ data: '{"id":"FN-001"}' } as MessageEvent);
});
expect(result.current.lastEvent?.type).toBe("task:deleted");
// Test task:merged
const mergedHandler = vi.mocked(mockEventSource.addEventListener).mock.calls.find(
(call) => call[0] === "task:merged",
)?.[1] as (event: MessageEvent) => void;
act(() => {
mergedHandler?.({ data: '{"id":"FN-001"}' } as MessageEvent);
config.events?.["task:merged"]?.({ data: '{"id":"FN-001"}' } as MessageEvent);
});
expect(result.current.lastEvent?.type).toBe("task:merged");
});
it("closes EventSource on unmount", () => {
it("calls unsubscribe on unmount", () => {
const { unmount } = renderHook(() => useRemoteNodeEvents("node_abc"));
const { unsubscribe } = capturedConfigs[0];
act(() => {
mockEventSource.onopen?.({});
});
expect(mockEventSource.close).not.toHaveBeenCalled();
expect(unsubscribe).not.toHaveBeenCalled();
unmount();
expect(mockEventSource.close).toHaveBeenCalledTimes(1);
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
it("closes EventSource and reconnects on error", () => {
const { result } = renderHook(() => useRemoteNodeEvents("node_abc"));
act(() => {
mockEventSource.onopen?.({});
});
expect(result.current.isConnected).toBe(true);
// Simulate error
act(() => {
mockEventSource.onerror?.({});
});
expect(mockEventSource.close).toHaveBeenCalledTimes(1);
expect(result.current.isConnected).toBe(false);
// Advance timer to trigger reconnect
act(() => {
vi.advanceTimersByTime(3000);
});
// Should have created a new EventSource
expect(vi.mocked(EventSource)).toHaveBeenCalledTimes(2);
});
it("cleans up heartbeat timer on unmount", () => {
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
const { unmount } = renderHook(() => useRemoteNodeEvents("node_abc"));
act(() => {
mockEventSource.onopen?.({});
});
unmount();
expect(clearTimeoutSpy).toHaveBeenCalled();
});
it("closes previous EventSource when nodeId changes", () => {
it("closes previous subscription when nodeId changes", () => {
const { rerender } = renderHook(
({ nodeId }: { nodeId: string | null }) => useRemoteNodeEvents(nodeId),
{ initialProps: { nodeId: "node_abc" } },
);
act(() => {
mockEventSource.onopen?.({});
});
expect(mockEventSource.close).not.toHaveBeenCalled();
const { unsubscribe: firstUnsubscribe } = capturedConfigs[0];
// Change nodeId
rerender({ nodeId: "node_xyz" });
expect(mockEventSource.close).toHaveBeenCalledTimes(1);
// First subscription should have been cleaned up
expect(firstUnsubscribe).toHaveBeenCalledTimes(1);
// New subscription should have been created
expect(capturedConfigs.length).toBe(2);
});
it("closes EventSource on unmount", () => {
const { unmount } = renderHook(() => useRemoteNodeEvents("node_abc"));
it("cleans up subscription when component unmounts with error", () => {
const { result, unmount } = renderHook(() => useRemoteNodeEvents("node_abc"));
const { config, unsubscribe } = capturedConfigs[0];
// Simulate error
act(() => {
mockEventSource.onopen?.({});
config.onError?.({} as Event);
});
expect(mockEventSource.close).not.toHaveBeenCalled();
expect(result.current.isConnected).toBe(false);
unmount();
expect(mockEventSource.close).toHaveBeenCalledTimes(1);
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});
describe("reconnection timing", () => {
it("reconnects after RECONNECT_DELAY_MS (3000)", () => {
describe("event handler registration", () => {
it("registers all required event handlers", () => {
renderHook(() => useRemoteNodeEvents("node_abc"));
const { config } = capturedConfigs[0];
act(() => {
mockEventSource.onopen?.({});
});
expect(config.events).toHaveProperty("task:created");
expect(config.events).toHaveProperty("task:moved");
expect(config.events).toHaveProperty("task:updated");
expect(config.events).toHaveProperty("task:deleted");
expect(config.events).toHaveProperty("task:merged");
});
act(() => {
mockEventSource.onerror?.({});
});
it("registers onOpen and onError callbacks", () => {
renderHook(() => useRemoteNodeEvents("node_abc"));
const { config } = capturedConfigs[0];
expect(result => {
vi.mocked(EventSource).mock.calls.length === 1;
});
// Advance time but not enough for reconnect
act(() => {
vi.advanceTimersByTime(2000);
});
// Should not have reconnected yet
expect(vi.mocked(EventSource)).toHaveBeenCalledTimes(1);
// Advance remaining time
act(() => {
vi.advanceTimersByTime(1000);
});
// Should have reconnected
expect(vi.mocked(EventSource)).toHaveBeenCalledTimes(2);
expect(config.onOpen).toBeDefined();
expect(config.onError).toBeDefined();
});
});
});

View File

@@ -1531,4 +1531,95 @@ describe("useTasks", () => {
});
});
});
describe("sseEnabled option", () => {
it("subscribes to SSE when sseEnabled is omitted (default)", async () => {
renderHook(() => useTasks({ projectId: "test-project" }));
await waitFor(() => {
expect(MockEventSource.instances.length).toBeGreaterThanOrEqual(1);
});
});
it("subscribes to SSE when sseEnabled is true", async () => {
renderHook(() => useTasks({ projectId: "test-project", sseEnabled: true }));
await waitFor(() => {
expect(MockEventSource.instances.length).toBeGreaterThanOrEqual(1);
});
});
it("does not subscribe to SSE when sseEnabled is false", async () => {
renderHook(() => useTasks({ projectId: "test-project", sseEnabled: false }));
// Give some time for effects to run
await act(async () => {
await flushPromises();
});
expect(MockEventSource.instances.length).toBe(0);
});
it("unsubscribes from SSE when sseEnabled toggles from true to false", async () => {
const { rerender } = renderHook(
({ sseEnabled }: { sseEnabled?: boolean }) => useTasks({ projectId: "test-project", sseEnabled }),
{ initialProps: { sseEnabled: true } }
);
await waitFor(() => {
expect(MockEventSource.instances.length).toBeGreaterThanOrEqual(1);
});
const esBefore = MockEventSource.instances[0];
await act(async () => {
rerender({ sseEnabled: false });
});
// The previous EventSource should have been closed
expect(esBefore.close).toHaveBeenCalled();
// No new EventSource should have been created
await act(async () => {
await flushPromises();
});
expect(MockEventSource.instances.length).toBe(1); // Same instance, just closed
});
it("resubscribes to SSE when sseEnabled toggles from false to true", async () => {
const { rerender } = renderHook(
({ sseEnabled }: { sseEnabled?: boolean }) => useTasks({ projectId: "test-project", sseEnabled }),
{ initialProps: { sseEnabled: false } }
);
// No EventSource initially
await act(async () => {
await flushPromises();
});
expect(MockEventSource.instances.length).toBe(0);
// Toggle to true
await act(async () => {
rerender({ sseEnabled: true });
});
await waitFor(() => {
expect(MockEventSource.instances.length).toBeGreaterThanOrEqual(1);
});
});
it("still fetches initial tasks when sseEnabled is false", async () => {
mockFetchTasks.mockResolvedValue([
createMockTask({ id: "FN-001", title: "Test Task" }),
]);
renderHook(() => useTasks({ projectId: "test-project", sseEnabled: false }));
await waitFor(() => {
expect(mockFetchTasks).toHaveBeenCalled();
});
expect(MockEventSource.instances.length).toBe(0);
});
});
});

View File

@@ -2,11 +2,8 @@
* useRemoteNodeEvents hook - subscribes to SSE events from a remote node via the proxy.
*/
import { useCallback, useEffect, useRef, useState } from "react";
const RECONNECT_DELAY_MS = 3000;
/** If no SSE message (including heartbeat events) arrives within this window, force reconnect. */
const HEARTBEAT_TIMEOUT_MS = 45_000;
import { useEffect, useState } from "react";
import { subscribeSse } from "../sse-bus";
export interface RemoteNodeEvent {
type: string;
@@ -22,169 +19,43 @@ export interface UseRemoteNodeEventsResult {
/**
* Hook for subscribing to SSE events from a remote node via the proxy.
* Opens an EventSource to /api/proxy/:nodeId/events and listens for task events.
* Implements reconnection logic and heartbeat timeout detection.
* Uses the shared SSE bus for connection multiplexing, heartbeat, and reconnection.
*/
export function useRemoteNodeEvents(nodeId: string | null): UseRemoteNodeEventsResult {
const [isConnected, setIsConnected] = useState(false);
const [lastEvent, setLastEvent] = useState<RemoteNodeEvent | null>(null);
const [connectionNonce, setConnectionNonce] = useState(0);
// Refs for cleanup
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const heartbeatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Reset heartbeat watchdog on each message
const resetHeartbeat = useCallback(() => {
if (heartbeatTimerRef.current) {
clearTimeout(heartbeatTimerRef.current);
}
heartbeatTimerRef.current = setTimeout(() => {
// No message received within the timeout — connection is likely dead
handleConnectionError();
}, HEARTBEAT_TIMEOUT_MS);
}, []);
// Handle connection errors and schedule reconnect
const handleConnectionError = useCallback(() => {
// Clean up existing connection
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (heartbeatTimerRef.current) {
clearTimeout(heartbeatTimerRef.current);
heartbeatTimerRef.current = null;
}
setIsConnected(false);
// Schedule reconnect
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
reconnectTimerRef.current = setTimeout(() => {
reconnectTimerRef.current = null;
setConnectionNonce((n) => n + 1);
}, RECONNECT_DELAY_MS);
}, []);
// Set up EventSource connection
useEffect(() => {
// No nodeId means no connection needed
if (!nodeId) {
setIsConnected(false);
setLastEvent(null);
return;
}
// Clean up any existing connection
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (heartbeatTimerRef.current) {
clearTimeout(heartbeatTimerRef.current);
heartbeatTimerRef.current = null;
}
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
const url = `/api/proxy/${encodeURIComponent(nodeId)}/events`;
// Build SSE URL
const encodedNodeId = encodeURIComponent(nodeId);
const esUrl = `/api/proxy/${encodedNodeId}/events`;
const eventSource = new EventSource(esUrl);
eventSourceRef.current = eventSource;
// Start heartbeat watchdog
resetHeartbeat();
// Handle open event
eventSource.onopen = () => {
setIsConnected(true);
resetHeartbeat();
};
// Handle task:created events
eventSource.addEventListener("task:created", (event: Event) => {
resetHeartbeat();
const messageEvent = event as MessageEvent;
setLastEvent({
type: "task:created",
data: messageEvent.data,
});
return subscribeSse(url, {
events: {
"task:created": (e: MessageEvent) => {
setLastEvent({ type: "task:created", data: e.data });
},
"task:moved": (e: MessageEvent) => {
setLastEvent({ type: "task:moved", data: e.data });
},
"task:updated": (e: MessageEvent) => {
setLastEvent({ type: "task:updated", data: e.data });
},
"task:deleted": (e: MessageEvent) => {
setLastEvent({ type: "task:deleted", data: e.data });
},
"task:merged": (e: MessageEvent) => {
setLastEvent({ type: "task:merged", data: e.data });
},
},
onOpen: () => setIsConnected(true),
onError: () => setIsConnected(false),
});
// Handle task:moved events
eventSource.addEventListener("task:moved", (event: Event) => {
resetHeartbeat();
const messageEvent = event as MessageEvent;
setLastEvent({
type: "task:moved",
data: messageEvent.data,
});
});
// Handle task:updated events
eventSource.addEventListener("task:updated", (event: Event) => {
resetHeartbeat();
const messageEvent = event as MessageEvent;
setLastEvent({
type: "task:updated",
data: messageEvent.data,
});
});
// Handle task:deleted events
eventSource.addEventListener("task:deleted", (event: Event) => {
resetHeartbeat();
const messageEvent = event as MessageEvent;
setLastEvent({
type: "task:deleted",
data: messageEvent.data,
});
});
// Handle task:merged events
eventSource.addEventListener("task:merged", (event: Event) => {
resetHeartbeat();
const messageEvent = event as MessageEvent;
setLastEvent({
type: "task:merged",
data: messageEvent.data,
});
});
// Handle heartbeat events (named event type)
eventSource.addEventListener("heartbeat", () => {
resetHeartbeat();
});
// Handle errors
eventSource.onerror = () => {
handleConnectionError();
};
// Cleanup on unmount or when nodeId changes
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (heartbeatTimerRef.current) {
clearTimeout(heartbeatTimerRef.current);
heartbeatTimerRef.current = null;
}
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
setIsConnected(false);
};
}, [nodeId, connectionNonce, handleConnectionError, resetHeartbeat]);
}, [nodeId]);
return {
isConnected,

View File

@@ -36,11 +36,19 @@ export interface UseTasksOptions {
* Server-side full-text search across title, ID, description, and comments.
*/
searchQuery?: string;
/**
* When false, disables SSE live-update subscription to free browser
* HTTP/1.1 connection slots for other operations (e.g., mission detail fetches).
* Initial fetch and visibility-change refresh remain active regardless.
* Defaults to true.
*/
sseEnabled?: boolean;
}
export function useTasks(options?: UseTasksOptions) {
const projectId = options?.projectId;
const searchQuery = options?.searchQuery;
const sseEnabled = options?.sseEnabled ?? true;
const [tasks, setTasks] = useState<Task[]>([]);
// Once the user expands the archived column, we keep including archived tasks
// in subsequent refreshes for the lifetime of this hook instance.
@@ -150,7 +158,10 @@ export function useTasks(options?: UseTasksOptions) {
// This prevents tasks from the previous project from appearing during project switches.
// Connection lifecycle (reconnect + heartbeat) is owned by sse-bus so all
// /api/events consumers share one underlying EventSource.
// When sseEnabled is false, the subscription is skipped to free browser connection slots.
useEffect(() => {
if (sseEnabled === false) return;
const contextVersionAtStart = projectContextVersionRef.current;
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
@@ -258,7 +269,7 @@ export function useTasks(options?: UseTasksOptions) {
void refreshTasksRef.current();
},
});
}, [projectId]);
}, [projectId, sseEnabled]);
const createTask = useCallback(async (input: TaskCreateInput): Promise<Task> => {
const task = normalizeTask(await api.createTask(input, projectId));