Skip to content

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:

noSwap: [204, 304],
#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-element hx-status:400 / hx-status:40x / hx-status:4xx attribute (which merges an HCON fragment such as swap:none into 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 to body and document like any other. (composed: true would 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. #handleTriggerHeader sits in finally, 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 to document when !on.isConnected — the document listener still fires either way.
  • Ordering: finally runs after await this.swap(ctx), so on a swapping response the closeDialog event 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 contains dist/ext/hx-sse.js, dist/ext/hx-sse.min.js and dist/ext/hx-sse.min.js.map, in htmx.org@4.0.0. Confirmed present.
  • [pkg] package.json has a files entry "dist/ext/*.js" and no exports map, so the deep import import "htmx.org/dist/ext/hx-sse" resolves under Node's legacy/CJS-style resolution and under Vite (which appends .js from resolve.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 on EventSource and has had no htmx 4 release. [docs] "Migration from htmx 2.0" states htmx 4 "uses fetch() and ReadableStream instead of EventSource" and that sse-swap is removed. hx-sse.js even emits console.warn for the legacy sse-connect/sse-close/sse-swap attributes. 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:

<button id="connect">Connect</button>
<div hx-sse:connect="/events" hx-trigger="click from:#connect"></div>
All hx-trigger modifiers 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.0 was 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 are latest → 2.0.10, next → 4.0.0. npm install htmx.org still gives you htmx 2. htmx 4 is opt-in by explicit version.
  • [gh] v2.0.10 was 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:

  1. pauseOnBackground defaults to true for hx-sse:connect ([pkg], [docs]). The stream is closed whenever document.hidden, and reconnected on visibility. For a PWA that is mostly desirable, but it changes behaviour versus htmx 2's EventSource and is worth a conscious decision — hx-config="sse.pauseOnBackground:false" disables it.
  2. HX-Trigger as a request header is renamed HX-Source ([skill] Step 8) and its format changed from a bare id to tag#id. Worth grepping the Django side for any reads of HX-Trigger / HX-Trigger-Name on requests.
  3. hx-boost="false" on the skip-link in base.html: with hx-boost:inherited="true" on <body>, confirm the non-inherited hx-boost="false" still wins on the child. Not verified here.
  4. The shipped upgrade-check tool was never run. npx htmx.org@4.0.0 upgrade-check -- . (add --ext .html coverage 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.