Skip to content

Fix EventSource native handlers accumulating on unsubscribe/resubscribe cycles - #34

Open
alexanderkasten with Copilot wants to merge 3 commits into
developfrom
copilot/fix-eventsource-listeners-issue
Open

Fix EventSource native handlers accumulating on unsubscribe/resubscribe cycles#34
alexanderkasten with Copilot wants to merge 3 commits into
developfrom
copilot/fix-eventsource-listeners-issue

Conversation

Copilot AI commented Feb 19, 2026

Copy link
Copy Markdown
Contributor

Each time all listeners for an event name were removed and then re-added, a new anonymous forwarding function was attached to the native EventSource while the old one remained — causing duplicate (and compounding) message delivery.

Root cause

addEventListener guarded against duplicate native handlers via connection.listeners.has(eventName), but removeEventListener deleted the Map key when the Set emptied. No reference to the anonymous forwarder was retained, so it could never be removed from the native EventSource. On the next subscribe, the guard missed and a second handler was attached.

Changes

  • sse-manager.ts — Change the listeners Map value from Set<Listener> to { forwarder, userListeners }, storing the native forwarding function alongside the user-listener set.
    • addEventListener: creates a named forwarder, stores it in the entry, passes it to source.addEventListener.
    • removeEventListener: when userListeners empties, calls source.removeEventListener(eventName, forwarder) before deleting the Map key.
// Before — anonymous forwarder lost immediately, can never be removed
connection.listeners.set(eventName, new Set());
connection.source.addEventListener(eventName, (event) => {
  connection.listeners.get(eventName)?.forEach((l) => l(event));
});

// After — forwarder retained, removed when last listener unsubscribes
const userListeners: Set<Listener> = new Set();
const forwarder = (event: Event) => {
  userListeners.forEach((listener) => listener(event as MessageEvent));
};
connection.listeners.set(eventName, { forwarder, userListeners });
connection.source.addEventListener(eventName, forwarder);
// ...on last removal:
connection.source.removeEventListener(eventName, entry.forwarder);
connection.listeners.delete(eventName);
  • sse-manager.test.ts — Adds a test covering the unsubscribe/resubscribe cycle: verifies removeEventListener is called on the native source when the set empties, exactly one new handler is registered on resubscribe, and messages arrive exactly once.
Original prompt

This section details on the original issue you should resolve

<issue_title>Native EventSource listeners stack up after unsubscribe/resubscribe cycles</issue_title>
<issue_description>When a listener is removed and a new one is added for the same event name on the same connection, a second native handler gets attached to the EventSource. Messages start being delivered twice (and it compounds with each cycle).

What's happening

In sse-manager.ts, addEventListener attaches an anonymous forwarding function to the native EventSource:

// line 32-38
if (!connection.listeners.has(eventName)) {
  connection.listeners.set(eventName, new Set());
  connection.source.addEventListener(eventName, (event) => {  // anonymous, no reference kept
    const listeners = connection.listeners.get(eventName);
    listeners?.forEach((listener) => listener(event));
  });
}
connection.listeners.get(eventName)!.add(listener);

The guard on line 32 prevents duplicates as long as the Set exists. But removeEventListener deletes the Map key when the Set empties:

// line 48-50
listeners.delete(listener);
if (listeners.size === 0) {
  connection.listeners.delete(eventName);  // key gone
}

Next time addEventListener is called for that event name, connection.listeners.has(eventName) is false again, so a new anonymous handler gets attached to the EventSource. The old one is still there -- there's no stored reference to remove it.

Now two native handlers fan out to the same listener Set. Every message arrives twice.

How to reproduce

  1. Two components share the same SSE URL
  2. Component A subscribes to event "updates"
  3. Component A unmounts (its listener is removed, Set empties, key deleted)
  4. Component B subscribes to "updates" on the same connection
  5. Component B receives every message twice

This also happens with a single component that remounts (React StrictMode, navigation, etc.).

Suggested fix

Store the native forwarding function so it can be removed when the last user listener unsubscribes:

// store the forwarder alongside the listener set
connection.listeners.set(eventName, { forwarder, userListeners: new Set() });

// in removeEventListener, when userListeners is empty:
connection.source.removeEventListener(eventName, forwarder);
connection.listeners.delete(eventName);
```</issue_description>

## Comments on the Issue (you are @copilot in this section)

<comments>
</comments>

💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

@vercel

vercel Bot commented Feb 19, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
v0-next-sse-r1gecphzhel Ready Ready Preview, Comment, Open in v0 Feb 19, 2026 0:32am

Copilot AI changed the title [WIP] Fix duplicate EventSource listeners on unsubscribe/resubscribe Fix EventSource native handlers accumulating on unsubscribe/resubscribe cycles Feb 19, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Fixes an SSE subscription lifecycle bug where native EventSource handlers accumulate across unsubscribe/resubscribe cycles, leading to duplicate message delivery in the client SSE layer.

Changes:

  • Update SSEManager to store a per-event native forwarder function alongside the user listener set, enabling proper native handler removal when the last listener unsubscribes.
  • Add a Jest test covering the unsubscribe/resubscribe cycle to prevent regressions.
  • Update package-lock.json (includes many peer metadata changes).

Reviewed changes

Copilot reviewed 2 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/client/sse-manager.ts Retains native forwarder references per event name and removes them when the last user listener is removed.
src/client/sse-manager.test.ts Adds coverage for unsubscribe/resubscribe cycles and native handler detachment behavior.
package-lock.json Large lockfile metadata churn unrelated to the described SSE fix (needs confirmation/intent).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/client/sse-manager.test.ts Outdated
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.

Native EventSource listeners stack up after unsubscribe/resubscribe cycles

3 participants