Skip to content

Do not duplicate an element's SSE state when it is processed again - #191

Open
milanobrtlik wants to merge 2 commits into
bigskysoftware:mainfrom
milanobrtlik:fix/sse-eventsource-leak-on-reprocess
Open

Do not duplicate an element's SSE state when it is processed again#191
milanobrtlik wants to merge 2 commits into
bigskysoftware:mainfrom
milanobrtlik:fix/sse-eventsource-leak-on-reprocess

Conversation

@milanobrtlik

Copy link
Copy Markdown

Description

Processing an element that carries sse-connect a second time duplicated everything the
extension had set up for it: it opened a second EventSource and orphaned the first, and it
registered the element's sse-swap listeners a second time. Full analysis and a reproduction
of the connection leak are in #190.

Both halves have the same root cause. The extension keeps its state in the element's internal
data, and htmx clears that data in deInitNode() right before it re-fires
htmx:afterProcessNode — so the extension has no reference left to either the connection or
the listeners it created earlier. This is also why the obvious guard, checking
api.getInternalData(elt).sseEventSource before creating a new source, cannot work: it never
sees anything.

htmx:afterProcessNode re-fires whenever an element's attribute hash changes, which is what a
morph swap does — idiomorph preserves the node and mutates its attributes, then htmx
re-processes it. That combination has been supported since #165.

The fix keeps both kinds of state in WeakMaps that survive the internal data reset.

Connections. ensureEventSource now behaves the way ensureEventSourceOnElement's doc
comment already claims it does ("If a usable EventSource already exists, then it is returned"):

  • already connected to the same sse-connect (and same sse-close), not CLOSED — keep the
    stream, restore the reference in internal data, return. No reconnect, no dropped events.
  • sse-connect changed — close the previous stream, open the new one, rebind descendants.
  • sse-connect removed — close the previous stream. This leaked unrecoverably before, since
    internal data had already been wiped by then.
  • previous stream already CLOSED (the normal retry path) — unchanged behaviour, and no event
    is fired, so the existing backoff logic and its tests are untouched.

Listeners. registerSSE drops the listeners the element registered earlier before
registering it again, which makes it idempotent.

Two things fall out of this: re-processing an element while a reconnect was pending used to
produce two connections, and the pending retry now finds the fresh connection and returns; and
the onopen rebinding is now safe for descendants that were already registered against the new
connection in the same htmx.process pass.

Notes on specific lines

  • htmx:beforeCleanupElement and maybeCloseSSESource now share a closeEventSource() helper.
    That is not cosmetic: it has to fall back to the WeakMap, because on the replacement path
    the element's internal data has already been wiped, so a close that reads only internal data
    finds nothing and silently skips. The helper also clears internalData.sseEventSource after
    closing, so hasEventSource() stops reporting an already-closed connection as usable (the
    previous code left it dangling — see the commented-out // source = null).
  • var closeAttribute moved to the top of ensureEventSource because the reuse check needs it,
    and the duplicated api.getAttributeValue(elt, 'sse-connect') collapsed into one variable so
    the new else branch has something to attach to. Those are the only non-functional edits.

One open question

Closing on sse-connect change or removal fires htmx:sseClose with a new detail.type of
sourceReplaced. The docs list three values today (nodeReplaced, nodeMissing, message)
and live in the htmx repo, so this needs a companion one-paragraph docs PR — happy to open it.

Reusing nodeReplaced instead would avoid the cross-repo change, but it would be inaccurate:
the node is still there, and handlers that treat nodeReplaced as "this element is going away"
would run teardown against a live element. Let me know which you prefer and I will adjust.

Htmx version: 2.0.4
Used extension(s) version(s): htmx-ext-sse 2.2.3

Corresponding issue: #190

Testing

10 new tests in src/sse/test/ext/sse.js. As CONTRIBUTING asks, they were written first and
fail against the current main:

1) reuses the existing EventSource when the element is processed again:
     expected [ Array(2) ] to have a length of 1 but got 2
2) keeps children bound to the reused EventSource:
     expected 'init' to equal 'Event 1'
3) replaces the EventSource when sse-connect changes:
     expected 1 to equal 2            (old connection still OPEN, never closed)
4) does not register duplicate listeners when a child is processed again:
     expected [ [Function listener], …(1) ] to have a length of 1 but got 2
5) closes the EventSource when sse-connect is removed:
     expected 1 to equal 2
6) does not open a duplicate EventSource when processed while a reconnect is pending:
     expected [ … ] to have a length of 2 but got 3
7) opens a new EventSource after the connection was closed

  31 passing
  7 failing

One of the new tests — does not register duplicate listeners when the element is processed again, covering <div sse-connect sse-swap> where one element both owns the connection and is
a swap target — passes against main for the wrong reason: with two connections open, the mock
only ever fires an event on one of them, so the duplicate swap does not show up in the harness.
It fails against the connection fix alone (2 listeners instead of 1), which is what it is
there to pin. The real-browser numbers below show the effect that the mock cannot.

With the whole change applied, all 38 pass (28 existing + 10 new):

  38 passing (37ms)

beforeEach gained an eventSources array so a test can count how many connections were
created. The mock itself is unchanged.

Manual testing: I ran the real extension in headless Chrome against a small local SSE server
that counts open connections and broadcasts exactly one named event on request. The
sse-connect URL is deliberately relative — the test mock stores the URL verbatim, whereas a
real EventSource resolves .url to an absolute URL, so this is the one thing the suite cannot
demonstrate. Loading the page, then mutating an attribute and calling htmx.process():

                                                  main    this PR
connections open after re-process                  2         1
swaps caused by ONE server event, before           1         1
swaps caused by ONE server event, after            2         1

Checklist

  • I have read the contribution guidelines
  • I ran the test suite locally (npm run test) and verified that it succeeded

htmx re-fires htmx:afterProcessNode whenever an element's attributes
change, which is what a morph style swap does. Right before firing it,
htmx wipes the element's internal data, so the extension lost its
reference to the connection it had already opened and opened a second
one. The first stayed open forever, holding a connection slot on the
server and counting against the browser's per domain SSE limit.

Keep the connection in a WeakMap that survives the internal data reset,
so an element that is already connected to the same url keeps its
stream instead of opening another one. When the element asks for a
different connection, or drops sse-connect entirely, close the previous
stream and rebind the descendants that were listening on it.
registerSSE always added a listener and never removed the previous one,
and it could not: htmx wipes the element's internal data before
re-firing htmx:afterProcessNode, so sseEventListener no longer referred
to what had been registered earlier. An element processed twice ended up
listening for the same event twice and swapped every message twice.

Track the registered listeners in a WeakMap that survives the internal
data reset, and drop them at the start of registerSSE. This also makes
the onopen rebinding safe for descendants that were already registered
against the new connection in the same htmx.process pass.
@netlify

netlify Bot commented Jul 31, 2026

Copy link
Copy Markdown

Deploy Preview for htmx-extensions canceled.

Name Link
🔨 Latest commit c192076
🔍 Latest deploy log https://app.netlify.com/projects/htmx-extensions/deploys/6a6c9196db6b0400073692e0

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.

1 participant