Verifying the four htmx 4 claims on migrate-to-htmx4¶
Research for issue #6 (part of the htmx 4 map, #5).
Date: 2026-09-20. Everything below was checked against primary sources, not recalled.
Sources used¶
| Ref | Source |
|---|---|
[pkg] |
npm pack htmx.org@4.0.0 — the published tarball. Core read at dist/htmx.js, SSE extension at dist/ext/hx-sse.js. This is the shipped artefact, so it outranks the prose. |
[skill] |
dist/skills/htmx-upgrade-from-htmx2.md, the migration guide shipped inside the 4.0.0 package. |
[docs] |
https://four.htmx.org/extensions/hx-sse and https://four.htmx.org/docs — the htmx 4 documentation site. |
[gh] |
gh release view v4.0.0 --repo bigskysoftware/htmx, npm view htmx.org dist-tags, npm view htmx-ext-sse version. |
https://htmx.org/migration-guide-htmx-4/ and https://htmx.org/extensions/hx-sse/ both return
404 — htmx.org still serves the 2.x docs. The htmx 4 docs live on four.htmx.org. Noted so
nobody wastes time re-fetching those URLs.
Claim 1 — "4xx responses swap by default in htmx 4" → TRUE¶
Verified, and safe to have deleted the htmx:beforeSwap / shouldSwap workaround.
[pkg] dist/htmx.js, the only status gate in the response path:
#handleStatusCodes(ctx) {
let status = ctx.response.raw.status;
let noSwapStrings = this.config.noSwap.map(x => x + "");
let str = status + ""
for (let pattern of [str, str.slice(0, 2) + 'x', str[0] + 'xx']) {
if (noSwapStrings.includes(pattern)) { ctx.swap = "none"; return }
let statusValue = this.#attributeValue(ctx.sourceElement, "hx-status:" + pattern);
if (statusValue) { HCON.merge(statusValue, ctx); return; }
}
}
There is no shouldSwap flag anywhere in htmx 4. #issueRequest fires htmx:response:error for
status >= 400 but ignores its return value, then unconditionally calls #handleStatusCodes
and await this.swap(ctx). A 400 is not in noSwap, so it swaps.
[skill] Step 13, verbatim:
In htmx 2, 4xx and 5xx responses did not swap by default. In htmx 4, all responses swap except 204 (No Content) and 304 (Not Modified).
Answers to the sub-questions in the ticket:
- Configurable? Yes, three ways:
htmx.config.noSwap(accepts exact codes and the patterns"4xx"/"5xx"), the per-elementhx-status:400/hx-status:40x/hx-status:4xxattribute (which merges an HCON fragment such asswap:noneinto the ctx), and simply returning 204. - Status-range dependent? Matching is tried in the order
400,40x,4xx— so it is per-code, per-decade and per-class, first match wins. - Partial? No. It is the blanket default for every status outside
[204, 304].
Relevant to us: 204 is in the default noSwap, so the 204 + HX-Trigger pattern from
docs/adr/0002 keeps working unchanged — a 204 sets ctx.swap = "none" and does not blank the
dialog. And every validation form in the repo carries its own explicit hx-target="#dialog-container"
(add_sheet.html, edit_sheet.html, schedule_sheet.html, quick_add_sheet.html,
delete_dialog.html), so the 400 re-render does not rely on htmx 4's removed implicit inheritance.
Verdict: TRUE. No bug. CLAUDE.md's "HTMX skips 4xx swaps by default" paragraph is now wrong and
must be updated.
Claim 2 — "HX-Trigger header events reach a bare document listener" → TRUE¶
[pkg] dist/htmx.js, in the finally block of #issueRequest:
if (ctx.hx?.trigger) { // HX-Trigger
this.#handleTriggerHeader(ctx.hx.trigger, ctx.sourceElement);
}
#handleTriggerHeader(value, elt) {
if (value[0] === '{') { /* HCON form, supports a per-trigger `target` */ }
else { value.split(',').forEach(name => this.trigger(elt, name.trim(), {})); }
}
trigger(on, eventName, detail = {}, bubbles = true) {
on = this.#normalizeElement(on)
let evt = new CustomEvent(eventName, { detail, cancelable: true, bubbles, composed: true });
let target = on?.isConnected ? on : document;
let result = !detail.cancelled && target.dispatchEvent(evt);
return result
}
So: the branch's comment is accurate — dispatched on ctx.sourceElement — and the event is
created with bubbles: true, composed: true. It therefore propagates source → … → document.
Sub-questions:
- Inside a
<dialog>? Yes.<dialog>/showModal()is top-layer rendering, not a shadow root. The element stays in the ordinary DOM tree, so the event bubbles through the dialog tobodyanddocumentlike any other. (composed: truewould also carry it out of a shadow root, which we do not have.) - On a
204 No Content? Yes, and this is the strongest case.#handleTriggerHeadersits infinally, so it runs regardless of status, of#handleHeadersAndMaybeReturnEarly, and of whether a swap happened. On a 204 no swap occurs, so the submitting form is still connected and the event starts at the form. Even if a swap had removed the source,trigger()falls back todocumentwhen!on.isConnected— thedocumentlistener still fires either way. - Ordering:
finallyruns afterawait this.swap(ctx), so on a swapping response thecloseDialogevent arrives after the swap. Irrelevant for our 204 path.
One thing the branch must not regress: [skill] Step 8 removes HX-Trigger-After-Swap and
HX-Trigger-After-Settle. We only send plain HX-Trigger, so nothing to do.
Verdict: TRUE. No bug. Deleting the document.body htmx:trigger listener is correct.
Claim 3 — "The SSE extension ships in core at htmx.org/dist/ext/hx-sse" → TRUE¶
[pkg]The tarball containsdist/ext/hx-sse.js,dist/ext/hx-sse.min.jsanddist/ext/hx-sse.min.js.map, inhtmx.org@4.0.0. Confirmed present.[pkg]package.jsonhas afilesentry"dist/ext/*.js"and noexportsmap, so the deep importimport "htmx.org/dist/ext/hx-sse"resolves under Node's legacy/CJS-style resolution and under Vite (which appends.jsfromresolve.extensions). The path is valid as written.[docs]The install snippet is<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-sse.min.js"></script>.[gh]npm view htmx-ext-sse version→ 2.2.4, last modified 2025-10-18. It is an htmx 2 extension built onEventSourceand has had no htmx 4 release.[docs]"Migration from htmx 2.0" states htmx 4 "usesfetch()andReadableStreaminstead ofEventSource" and thatsse-swapis removed.hx-sse.jseven emitsconsole.warnfor the legacysse-connect/sse-close/sse-swapattributes. Genuinely superseded.
One load-order note, satisfied by the current frontend/main.js: dist/ext/hx-sse.js is a bare
IIFE that references the global htmx and calls htmx.registerExtension('sse', …). The ESM
build (dist/htmx.esm.js) is what sets window.htmx. import htmx from "htmx.org" must therefore
come before import "htmx.org/dist/ext/hx-sse" — it does.
Also: hx-ext="sse" is gone. [skill] Step 2 — hx-ext="..." → "Remove (just including the
extension script is enough)". The branch removing it is correct.
Verdict: TRUE. No bug.
Claim 4 — "hx-sse:connect dispatches the named event on body" → FALSE, and the branch has a live bug¶
Two separate problems. The second one is fatal.
4a. The event is dispatched on the connecting element, not on body¶
[pkg] dist/ext/hx-sse.js, in the message loop (element is ctx.sourceElement, i.e. the
element carrying hx-sse:connect):
if (detail.message.event) {
if (detail.message.event === 'hx:release') release();
htmx.trigger(element, detail.message.event, {data: detail.message.data, id: detail.message.id});
...
}
[docs], "Trigger Client Events": "Named events dispatch DOM events instead of swapping", and the
event.detail shape is {data, id}.
So the claim as written is wrong: the event fires on #events-list / #wishlist-list, not on
body. It reaches body only because htmx.trigger() defaults to bubbles: true (see Claim 2),
and hx-trigger="… from:body" compiles to body.addEventListener('update', handler) — [pkg]
#onTrigger:
let fromElts = [elt];
if (spec.from === 'outside') fromElts = [document];
else if (spec.from && spec.from !== 'self') fromElts = this.#findAllExt(elt, spec.from);
...
for (let fromElt of fromElts) { fromElt.addEventListener(eventName, spec.handler, listenerInfo); }
A bubbled event hits that listener. So from:body is the right spelling and the documented
pattern — [docs] gives <div hx-get="/status" hx-trigger="progress from:body"></div> verbatim,
including in its htmx-2 migration table. The reasoning in the branch's comment is wrong; the
spelling happens to be right.
4b. Putting hx-trigger on the hx-sse:connect element deadlocks the connection — live bug¶
[pkg] dist/ext/hx-sse.js:
function processElement(element) {
let connectUrl = api.attributeValue(element, 'hx-sse:connect');
if (!connectUrl) return;
if (element._htmx?.sse) return;
let hxTrigger = api.attributeValue(element, 'hx-trigger') || 'load';
api.onTrigger(element, hxTrigger, () => {
if (element._htmx?.sse) return;
htmx.ajax('GET', connectUrl, {source: element});
});
}
[docs], under hx-sse:connect → "Defaults: hx-trigger="load"", and:
Trigger connection after an event:
All<button id="connect">Connect</button> <div hx-sse:connect="/events" hx-trigger="click from:#connect"></div>hx-triggermodifiers are supported.
hx-trigger on an hx-sse:connect element means "when do I open the stream", not "when do I
re-fetch". The branch writes both on the same <div>:
<div id="events-list"
hx-sse:connect="/items/stream/"
hx-trigger="update from:body"
hx-get="{% url 'events_list_partial' %}"
hx-swap="innerHTML">
The extension therefore wires the connection to update from:body. The only thing that can emit
update is the stream that has not been opened. Nothing ever opens it; the calendar and the
wishlist get no SSE at all. Both events_page.html and wishlist_page.html are affected. This
is not theoretical — real-time sync across devices, the whole point of the feature, is dead on this
branch.
Worse, it fails silently: no error, no console warning, the page just never updates.
(hx-target/hx-swap on the connect element are also claimed by the extension "for messages" per
[docs], which is a second reason not to pile the refresh request onto the same element.)
What the branch must do instead¶
Split the two responsibilities onto two elements — the connector, and the refresher:
<!-- opens the stream; hx-trigger omitted so it defaults to load -->
<div hx-sse:connect="/items/stream/"></div>
<div id="events-list"
hx-trigger="update from:body"
hx-get="{% url 'events_list_partial' %}"
hx-swap="innerHTML">
{% include "items/calendar/partials/events_list.html" %}
</div>
Same shape for wishlist_page.html (keeping its hx-vals). Note /wishlist/stream/ exists too;
both URLs serve the same "items" channel, so either works.
Event-name mapping, to close the ticket's last sub-question: django-eventstream writes
event: update into the stream; parseSSE puts that in msg.event; the extension calls
htmx.trigger(element, 'update', …). The DOM event name is the server's event name verbatim —
no sse: prefix any more (that was htmx 2), no transformation. hx-trigger="update from:body" is
the correct spelling once the connection is on its own element.
Verdict: FALSE as stated, and the markup derived from it is broken. SSE does not connect on this branch.
Bonus: is 4.0.0 stable, and what is the 4.x support story?¶
[gh]v4.0.0was released 2026-08-28,isPrerelease: false, and is GitHub's "Latest" release. It is a real GA tag, not an RC.[gh]But npm dist-tags arelatest → 2.0.10,next → 4.0.0.npm install htmx.orgstill gives you htmx 2. htmx 4 is opt-in by explicit version.[gh]v2.0.10was published 2026-09-06, i.e. after 4.0.0 — the 2.x line is still being maintained. No published EOL date for 2.x was found;[docs]says nothing about support windows.- htmx 4 is described by
[skill]as "a ground-up rewrite", and ships explicit escape hatches (htmx-2-compat.js,implicitInheritance: true,noSwap: [204, 304, "4xx", "5xx"]) — a strong signal the maintainers expect churn.
Bearing on the map's "version pinning" open question: given latest is still 2.x and 4.0.0 is only
three weeks old with no 4.0.x patch yet, the exact pin "htmx.org": "4.0.0" is the defensible
choice for now. A caret would be a bet on a 4.x line that has no track record.
Other things spotted while reading, not part of the four claims¶
Flagging, not fixing:
pauseOnBackgrounddefaults totrueforhx-sse:connect([pkg],[docs]). The stream is closed wheneverdocument.hidden, and reconnected on visibility. For a PWA that is mostly desirable, but it changes behaviour versus htmx 2'sEventSourceand is worth a conscious decision —hx-config="sse.pauseOnBackground:false"disables it.HX-Triggeras a request header is renamedHX-Source([skill]Step 8) and its format changed from a bare id totag#id. Worth grepping the Django side for any reads ofHX-Trigger/HX-Trigger-Nameon requests.hx-boost="false"on the skip-link inbase.html: withhx-boost:inherited="true"on<body>, confirm the non-inheritedhx-boost="false"still wins on the child. Not verified here.- The shipped
upgrade-checktool was never run.npx htmx.org@4.0.0 upgrade-check -- .(add--ext .htmlcoverage for Django templates — it already scans.html,.jinja,.j2) would have produced a worklist for exactly this migration. Cheap, and it should be run before the branch is trusted.