Skip to content

internal(coin-app): Harden StreamManager reconnect and subscriptions - #4123

Merged
ntucker merged 4 commits into
masterfrom
claude/project-thread-w5ex2e
Oct 4, 2026
Merged

ntucker merged 4 commits into
masterfrom
claude/project-thread-w5ex2e

Conversation

@ntucker

@ntucker ntucker commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Requested by Nathaniel · project thread

Follow-up to #4103.

Motivation

The coin app's StreamManager had several pre-existing problems:

  • cleanup() didn't cancel a pending reconnect() timer, so it could open a new socket after unmount.
  • Reconnects received no data: the subscribe flush only listened for open on the first socket.
  • One component unsubscribing a product killed the feed for any other component still showing it.
  • After cleanup → init, the old socket's onerror called this.evtSource.close(), closing the new socket.
  • tsc error in send() (ArrayBufferView<ArrayBufferLike> vs ArrayBufferView<ArrayBuffer>), plus an unused ctrl param and a msgQueue that was never drained.

Solution

Before:

reconnect() {
  setTimeout(() => { this.attempts++; this.connect(); }, backoff);
}
cleanup() {
  this.evtSource.onclose = null;
  this.evtSource.close();
  clearTimeout(this.flushTimeout);
}

After:

reconnect() {
  this.reconnectTimeout = setTimeout(() => {
    this.reconnectTimeout = undefined;
    this.attempts++;
    this.connect();
  }, backoff);
}
cleanup() {
  // detach handlers so the closing socket can't reconnect or touch a new one
  this.evtSource.onopen = null;
  this.evtSource.onmessage = null;
  this.evtSource.onclose = null;
  this.evtSource.onerror = null;
  this.evtSource.close();
  clearTimeout(this.reconnectTimeout);
  // ...
}
  • Subscriptions are ref-counted per product. useSubscription dispatches SUBSCRIBE/UNSUBSCRIBE per hook mount, so a count is the right model.
  • A debounced sync() sends the difference between active subscriptions and what the current socket was sent, so subscribes and unsubscribes are batched. onopen resets the sent set and syncs, so every reconnect re-subscribes. This replaces the never-drained msgQueue and the send() with the type error.
  • handleMessage(msg) drops the unused controller.

The docs snippet in docs/core/concepts/managers.md has no reconnect, send() or handleMessage, so it needs no change. This is a private example, so there's no changeset.

Validated with tsc --noEmit in examples/coin-app (the send() error reproduced before the change and is gone after), eslint, prettier, and a mock-WebSocket simulation of subscribe/unsubscribe batching, reconnect re-subscription and cleanup.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MhCkU7NpfSuvJiu4NyRNB6


Note

Low Risk
Changes are confined to the coin-app example’s WebSocket manager; no auth, data persistence, or shared library API surface.

Overview
Hardens the coin-app StreamManager WebSocket lifecycle and subscription handling so reconnects, multi-component usage, and teardown behave correctly.

Subscription state moves from a flat product_ids list and never-drained msgQueue/send() to ref-counted products plus a debounced sync() that batches subscribe/unsubscribe deltas against what the current socket already has; onopen clears sent and re-syncs so every reconnect restores active feeds. UNSUBSCRIBE goes through unsubscribe() instead of a one-off wire message, so one hook unmounting no longer drops a product other components still use.

cleanup() now nulls all socket handlers (avoiding a stale socket closing a replacement after init), cancels pending reconnect and sync timers, and reconnect() stores its timeout id for that cancellation. handleMessage drops the unused controller argument; unused ActionTypes import is removed.

Reviewed by Cursor Bugbot for commit b9e3d22. Bugbot is set up for automated code reviews on this repo. Configure here.

- Cancel pending reconnect timer in cleanup() so it can't reopen a socket after unmount
- Re-subscribe active products on every (re)connect; previously reconnects received nothing
- Ref-count subscriptions per product so one unmount doesn't kill a shared feed
- Ignore events from replaced sockets (stale onerror could close the new socket)
- Fix send() param type (tsc error) and drop never-drained msgQueue
- Remove unused handleMessage ctrl param and ActionTypes import

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MhCkU7NpfSuvJiu4NyRNB6
@changeset-bot

changeset-bot Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: b9e3d22

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@ntucker ntucker self-assigned this Oct 3, 2026
@vercel

vercel Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
docs-site Ignored Ignored Preview Oct 4, 2026 4:08pm UTC

Request Review

@codecov

codecov Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.93%. Comparing base (f03332e) to head (b9e3d22).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #4123   +/-   ##
=======================================
  Coverage   97.93%   97.93%           
=======================================
  Files         158      158           
  Lines        3095     3095           
  Branches      619      619           
=======================================
  Hits         3031     3031           
  Misses         18       18           
  Partials       46       46           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

claude added 3 commits October 4, 2026 09:12
- Replace pending product_ids batch with a debounced sync() that diffs
  active subscriptions against what the socket was sent; unsubscribes
  are now batched too
- Detach all socket handlers in cleanup() instead of guarding each one
- Spell the subscribe/unsubscribe message once in sendChannel()

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MhCkU7NpfSuvJiu4NyRNB6
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MhCkU7NpfSuvJiu4NyRNB6
@ntucker
ntucker merged commit 578000e into master Oct 4, 2026
22 checks passed
@ntucker
ntucker deleted the claude/project-thread-w5ex2e branch October 4, 2026 16:58
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.

2 participants