Skip to content

[#850] Fix EventSource reconnect storm/leak in useStreamEvents - #996

Closed
Hollujay wants to merge 6 commits into
LabsCrypt:mainfrom
Hollujay:fix/850-usestreamevents-eventsource-recreation
Closed

[#850] Fix EventSource reconnect storm/leak in useStreamEvents#996
Hollujay wants to merge 6 commits into
LabsCrypt:mainfrom
Hollujay:fix/850-usestreamevents-eventsource-recreation

Conversation

@Hollujay

@Hollujay Hollujay commented Jul 27, 2026

Copy link
Copy Markdown

Summary

useStreamEvents was tearing down and recreating the EventSource on every render, and since each incoming event re-renders the consumer, the stream ended up reconnecting on every event in a self-sustaining reconnect loop.

Root cause

buildUrl was a useCallback depending directly on streamIds, which is always a fresh array reference every render (both the default [] and every call site passing inline array literals). New reference → new buildUrl → new connect → mount useEffect([connect]) tears down the old EventSource and creates a new one. Incoming events → setEvents → re-render → cycle repeats.

Changes

frontend/src/hooks/useStreamEvents.ts

  1. Stable subscription identity — A subscriptionKey string is derived from sorted streamIds, subscribeToAll, and jwtToken via useMemo. buildUrl depends on this key rather than the raw values, so it only changes when the subscription content changes — inline array identity changes on every render no longer cause a reconnect.

  2. Reconnect timer cleared before rescheduling — Both the connect entry point and the onerror handler now clear any pending reconnect timeout before scheduling a new one, preventing duplicate or stale timers.

  3. Reconnect attempt cap — Added MAX_RECONNECT_ATTEMPTS = 20 with a reconnectAttemptsRef counter. Incremented on each reconnect attempt; once the cap is exceeded no further reconnection timers are set. The counter resets to 0 on a successful onopen.

frontend/src/__tests__/useStreamEvents.test.tsx

Two new regression tests:

  • "creates only one EventSource across multiple re-renders and incoming events" — Renders the hook, re-renders multiple times with the same inline streamIds array, emits events (which trigger setEvents → consumer re-render), re-renders again. Asserts instanceCount === 1 and the same MockEventSource instance is reused throughout.

  • "stops reconnecting after reaching the cap" — Triggers 25 consecutive errors without a successful connection. Asserts at most 21 EventSource instances were created (1 initial + 20 capped reconnects).

Call sites (no changes needed)

All three call sites pass inline arrays — this is exactly the pattern that was broken before, and the fix handles it transparently because the stable key is derived from array contents, not reference:

  • NotificationDropdown.tsxuserPublicKeys: [publicKey]
  • dashboard-view.tsxuserPublicKeys: [session.publicKey]
  • stream-details-content.tsxstreamIds: [streamId]

Verification

  • npm test — 11/11 tests pass (9 existing + 2 new)
  • npm run lint — no new warnings or errors
  • npx tsc --noEmit — no new type errors

Closes #850

Use a stable subscription key derived from sorted streamIds,
subscribeToAll, and jwtToken so buildUrl/connect only change
when the subscription actually changes — not on every render
due to inline array literals or default [] identity.

- Derive subscriptionKey as a stable memoized string
- Clear pending reconnect timer before scheduling a new one
- Cap reconnect attempts at 20 (configurable via constant)

Fixes LabsCrypt#850
@Hollujay

Copy link
Copy Markdown
Author

CI failures here are pre-existing and unrelated to this fix:

  • Frontend CI / Backend CI: npm ci fails on a peer dependency conflict —
    @vitest/ui@3.2.7 requires vitest ^3.2.7, but @vitest/coverage-v8@2.1.9
    requires vitest ^2.1.9. These can't both resolve; unrelated to this PR's
    scope (a frontend hook fix touches no dependencies).
  • Soroban Contracts CI: fails compiling the ethnum crate with
    error[E0512]: cannot transmute between types of different sizes — a
    toolchain/crate version incompatibility, unrelated to any frontend code.

Local verification (noted in the PR description) passed cleanly: 11/11 tests,
no lint/type errors.

Hollujay added 5 commits July 29, 2026 15:30
1. Backend CI Build: Fix TS2532 errors with proper null/undefined checks
   - events-wire-format.test.ts: Use nullish coalescing fallback for
     HANDLER_READ_FIELDS lookup
   - stream.validator.test.ts: Add expect(result.error).toBeDefined()
     before accessing issues array

2. Frontend CI Lint: Fix parsing errors and cleanup
   - Rename useIncomingStreams.test.ts to .tsx (contains JSX)
   - Fix malformed try/catch block indentation in dashboard.ts fetchStreams
   - Remove unused useDashboard import from dashboard-view.tsx
   - Remove 4 unused eslint-disable no-console directives from logger.ts

3. Soroban Contracts CI: Run cargo fmt --all (rustfmt)

4. Backend npm test: Fix i128 decode error in stream-lifecycle test
   - Root cause: nativeToScVal(BigInt) without explicit type creates
     non-i128 ScVal; fix uses scvI128 for BigInt override values
- stream.validator.test.ts: assert issues.length > 0 before accessing [0]
- useIncomingStreams.test.tsx: add eslint-disable for no-explicit-any in tests
- Frontend: Fix useIncomingStreams test timeout by mocking
  fetchIncomingStreams with matching stream data so the
  pollIndexerForWithdraw loop exits on the first attempt
  instead of timing out after 63s of exponential backoff.
  This test was previously skipped (`.ts` with JSX couldn't
  parse); the `.tsx` rename exposed a pre-existing mock bug.

- Backend: Add fileParallelism: false to vitest config so
  integration tests sharing the same database don't clobber
  each other's cleanup steps (foreign key violations).
- Frontend: Clear setQueryData spy after mutateAsync so the assertion
  only catches the poll's call, not any optimistic onMutate update.

- CI: Add --no-file-parallelism to Backend CI and pr-test-gate
  workflows instead of baking it into vitest.config.ts so local
  devs can still run fast parallel tests.

@ogazboiz ogazboiz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the hook fix is the right approach: memoized subscriptionKey, clear-before-reschedule, and a reconnect cap all address #850 properly, and the new tests are welcome. but the branch carries a lot that is not the hook:

  1. rebase onto main (currently conflicting).
  2. split out or drop the CI workflow edits (--no-file-parallelism in ci.yml and pr-test-gate.yml), contracts test.rs formatting, logger.ts, and dashboard changes. keep the hook fix plus its tests.
  3. the useIncomingStreams.test.ts to .tsx rename collides with #1070 and #1001, coordinate there.
  4. confirm green CI post-rebase.

if you want to keep contributing, join us on Telegram: https://t.me/+DOylgFv1jyJlNzM0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Frontend] useStreamEvents tears down and recreates the EventSource on every render - SSE reconnect storm/leak

2 participants