Hoist SSE connection to module-singleton so route nav stops spamming Firefox console #605

Closed
opened 2026-04-30 20:58:42 +00:00 by claude-desktop · 0 comments
Collaborator

User story

As an operator using the dashboard in Firefox, I want a single shared EventSource for /events that survives route navigation, so that the console stops printing La connexion avec https://claude.jacquin.app/events a été interrompue pendant le chargement de la page. every time I move between pages.

Background

apps/web/src/lib/sse.ts:68 exposes useSSE(), and every top-level route mounts its own copy:

  • apps/web/src/routes/planner.board.tsx:143
  • apps/web/src/routes/agents.tsx:155
  • apps/web/src/routes/workspace.index.tsx:162
  • apps/web/src/routes/flows.$flowId.tsx:35
  • apps/web/src/routes/flows.$flowId.v.$version.tsx:20
  • apps/web/src/routes/flows.$flowId.versions.tsx:21
  • apps/web/src/routes/flows.new.tsx:24
  • apps/web/src/routes/settings.index.tsx:44
  • apps/web/src/routes/settings.repos.tsx:38

Each useSSE call creates its own EventSource in useEffect (sse.ts:123) and closes it on cleanup (sse.ts:215-223). Switching routes therefore tears down the previous stream and opens a new one. Firefox logs the cited "interrompue pendant le chargement" message on every torn-down connection. Functionally harmless — the new stream connects fine, the conn pill stays green — but the console spam is annoying and easy to mistake for a real problem.

StrictMode in apps/web/src/main.tsx:40 adds one extra mount/cleanup pair per page in dev mode, which compounds the issue locally.

The pre-existing initialConnectionToastShown module-level latch (sse.ts:36) hints that this came up already in #493, but only the toast got de-duped — the underlying connection still re-opens.

Acceptance criteria

Singleton connection

  • EventSource lives at module scope inside apps/web/src/lib/sse.ts, not inside the hook's useEffect.
  • Hook subscribers are reference-counted: first subscriber opens the connection; last unsubscribe schedules a close after a small grace window (≈ 1 s) so back-to-back unmount/mount during route nav or StrictMode does not tear the stream.
  • If a new subscriber arrives during the grace window, the close is cancelled and the existing EventSource keeps running.

Multiplexed callbacks

  • onEvent and onReconnect from each subscriber are stored in module-level listener sets and fired for every active subscriber on each SSE message / reconnect — no subscriber misses an event because another subscriber happened to mount more recently.
  • Connection state (live / reconnecting / disconnected, reconnectCount, offlineFor) is also module-level and broadcast to every hook instance via a small store (plain listener set + useState re-render, or useSyncExternalStore).

Public API stays stable

  • useSSE(opts) continues to return { state, reconnectCount, offlineFor, forceReconnect } with the same semantics. No call sites need to change.
  • forceReconnect() still tears down + reopens the shared connection (does not close it permanently).
  • _resetInitialConnectionToastForTest is preserved, plus an analogous reset hook for the new module state so unit tests can isolate.

Firefox console

  • Manual verification: navigate among /planner/board, /agents, /workspace, /flows/*, /settings, /settings/repos repeatedly. The interrompue pendant le chargement warning no longer appears for /events (one initial open + one close on tab close is acceptable).
  • Conn pill still flips through livereconnectingdisconnected correctly when the server is killed; reconnects without a manual page reload.

Tests

  • Add apps/web/src/lib/sse.test.ts covering: refcount open-on-first / close-after-last, grace-window cancel on quick remount, multiplexed onEvent to two subscribers, forceReconnect keeps the shared connection alive for other subscribers.

Out of scope

  • Server-side /events handler changes (apps/server/src/main.ts:1108) — unaffected.
  • Reverse-proxy buffering / timeout tweaks (Caddy / nginx). The current Bun side already calls srv.timeout(req, 0) for /events (main.ts:2984); revisit only if the conn pill flaps after this fix.
  • Replacing EventSource with WebSocket or fetch-based streaming.

References

  • apps/web/src/lib/sse.ts — current per-hook EventSource lifecycle
  • apps/web/src/main.tsx:40StrictMode wrapper (compounds the issue in dev)
  • All useSSE consumers listed under Background
  • Issue #493 — the prior pass that added the connection-toast latch; this ticket finishes the job at the connection layer.
## User story As an operator using the dashboard in Firefox, I want a single shared `EventSource` for `/events` that survives route navigation, so that the console stops printing `La connexion avec https://claude.jacquin.app/events a été interrompue pendant le chargement de la page.` every time I move between pages. ## Background `apps/web/src/lib/sse.ts:68` exposes `useSSE()`, and every top-level route mounts its own copy: - `apps/web/src/routes/planner.board.tsx:143` - `apps/web/src/routes/agents.tsx:155` - `apps/web/src/routes/workspace.index.tsx:162` - `apps/web/src/routes/flows.$flowId.tsx:35` - `apps/web/src/routes/flows.$flowId.v.$version.tsx:20` - `apps/web/src/routes/flows.$flowId.versions.tsx:21` - `apps/web/src/routes/flows.new.tsx:24` - `apps/web/src/routes/settings.index.tsx:44` - `apps/web/src/routes/settings.repos.tsx:38` Each `useSSE` call creates its own `EventSource` in `useEffect` (`sse.ts:123`) and closes it on cleanup (`sse.ts:215-223`). Switching routes therefore tears down the previous stream and opens a new one. Firefox logs the cited "interrompue pendant le chargement" message on every torn-down connection. Functionally harmless — the new stream connects fine, the conn pill stays green — but the console spam is annoying and easy to mistake for a real problem. `StrictMode` in `apps/web/src/main.tsx:40` adds one extra mount/cleanup pair per page in dev mode, which compounds the issue locally. The pre-existing `initialConnectionToastShown` module-level latch (`sse.ts:36`) hints that this came up already in #493, but only the toast got de-duped — the underlying connection still re-opens. ## Acceptance criteria ### Singleton connection - [ ] `EventSource` lives at module scope inside `apps/web/src/lib/sse.ts`, not inside the hook's `useEffect`. - [ ] Hook subscribers are reference-counted: first subscriber opens the connection; last unsubscribe schedules a close after a small grace window (≈ 1 s) so back-to-back unmount/mount during route nav or StrictMode does not tear the stream. - [ ] If a new subscriber arrives during the grace window, the close is cancelled and the existing `EventSource` keeps running. ### Multiplexed callbacks - [ ] `onEvent` and `onReconnect` from each subscriber are stored in module-level listener sets and fired for every active subscriber on each SSE message / reconnect — no subscriber misses an event because another subscriber happened to mount more recently. - [ ] Connection state (`live` / `reconnecting` / `disconnected`, `reconnectCount`, `offlineFor`) is also module-level and broadcast to every hook instance via a small store (plain listener set + `useState` re-render, or `useSyncExternalStore`). ### Public API stays stable - [ ] `useSSE(opts)` continues to return `{ state, reconnectCount, offlineFor, forceReconnect }` with the same semantics. No call sites need to change. - [ ] `forceReconnect()` still tears down + reopens the shared connection (does not close it permanently). - [ ] `_resetInitialConnectionToastForTest` is preserved, plus an analogous reset hook for the new module state so unit tests can isolate. ### Firefox console - [ ] Manual verification: navigate among `/planner/board`, `/agents`, `/workspace`, `/flows/*`, `/settings`, `/settings/repos` repeatedly. The `interrompue pendant le chargement` warning no longer appears for `/events` (one initial open + one close on tab close is acceptable). - [ ] Conn pill still flips through `live` → `reconnecting` → `disconnected` correctly when the server is killed; reconnects without a manual page reload. ### Tests - [ ] Add `apps/web/src/lib/sse.test.ts` covering: refcount open-on-first / close-after-last, grace-window cancel on quick remount, multiplexed `onEvent` to two subscribers, `forceReconnect` keeps the shared connection alive for other subscribers. ## Out of scope - Server-side `/events` handler changes (`apps/server/src/main.ts:1108`) — unaffected. - Reverse-proxy buffering / timeout tweaks (Caddy / nginx). The current Bun side already calls `srv.timeout(req, 0)` for `/events` (`main.ts:2984`); revisit only if the conn pill flaps after this fix. - Replacing `EventSource` with WebSocket or `fetch`-based streaming. ## References - `apps/web/src/lib/sse.ts` — current per-hook `EventSource` lifecycle - `apps/web/src/main.tsx:40` — `StrictMode` wrapper (compounds the issue in dev) - All `useSSE` consumers listed under Background - Issue #493 — the prior pass that added the connection-toast latch; this ticket finishes the job at the connection layer.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks#605
No description provided.