Do not duplicate an element's SSE state when it is processed again - #191
Open
milanobrtlik wants to merge 2 commits into
Open
Do not duplicate an element's SSE state when it is processed again#191milanobrtlik wants to merge 2 commits into
milanobrtlik wants to merge 2 commits into
Conversation
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.
✅ Deploy Preview for htmx-extensions canceled.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Processing an element that carries
sse-connecta second time duplicated everything theextension had set up for it: it opened a second
EventSourceand orphaned the first, and itregistered the element's
sse-swaplisteners a second time. Full analysis and a reproductionof 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-fireshtmx:afterProcessNode— so the extension has no reference left to either the connection orthe listeners it created earlier. This is also why the obvious guard, checking
api.getInternalData(elt).sseEventSourcebefore creating a new source, cannot work: it neversees anything.
htmx:afterProcessNodere-fires whenever an element's attribute hash changes, which is what amorph 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.
ensureEventSourcenow behaves the wayensureEventSourceOnElement's doccomment already claims it does ("If a usable EventSource already exists, then it is returned"):
sse-connect(and samesse-close), notCLOSED— keep thestream, restore the reference in internal data, return. No reconnect, no dropped events.
sse-connectchanged — close the previous stream, open the new one, rebind descendants.sse-connectremoved — close the previous stream. This leaked unrecoverably before, sinceinternal data had already been wiped by then.
CLOSED(the normal retry path) — unchanged behaviour, and no eventis fired, so the existing backoff logic and its tests are untouched.
Listeners.
registerSSEdrops the listeners the element registered earlier beforeregistering 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
onopenrebinding is now safe for descendants that were already registered against the newconnection in the same
htmx.processpass.Notes on specific lines
htmx:beforeCleanupElementandmaybeCloseSSESourcenow share acloseEventSource()helper.That is not cosmetic: it has to fall back to the
WeakMap, because on the replacement paththe 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.sseEventSourceafterclosing, so
hasEventSource()stops reporting an already-closed connection as usable (theprevious code left it dangling — see the commented-out
// source = null).var closeAttributemoved to the top ofensureEventSourcebecause the reuse check needs it,and the duplicated
api.getAttributeValue(elt, 'sse-connect')collapsed into one variable sothe new
elsebranch has something to attach to. Those are the only non-functional edits.One open question
Closing on
sse-connectchange or removal fireshtmx:sseClosewith a newdetail.typeofsourceReplaced. The docs list three values today (nodeReplaced,nodeMissing,message)and live in the
htmxrepo, so this needs a companion one-paragraph docs PR — happy to open it.Reusing
nodeReplacedinstead would avoid the cross-repo change, but it would be inaccurate:the node is still there, and handlers that treat
nodeReplacedas "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 andfail against the current
main: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 isa swap target — passes against
mainfor the wrong reason: with two connections open, the mockonly 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 (
2listeners instead of1), which is what it isthere 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):
beforeEachgained aneventSourcesarray so a test can count how many connections werecreated. 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-connectURL is deliberately relative — the test mock stores the URL verbatim, whereas areal
EventSourceresolves.urlto an absolute URL, so this is the one thing the suite cannotdemonstrate. Loading the page, then mutating an attribute and calling
htmx.process():Checklist
npm run test) and verified that it succeeded