htmx 4 attribute audit (issue #7)¶
Audit of every hx-* attribute used in shared_calendar/templates/ on branch
migrate-to-htmx4 (commit 193e1fc) against htmx 4.0.0.
Date: 2026-09-20. No repo convention existed for research notes, so this lives in
docs/research/.
Sources (all primary)¶
- [S1] htmx 4 docs, What's New in htmx 4 — https://four.htmx.org/docs/whats-new-in-htmx-4
- [S2] htmx 4 attribute reference index — https://four.htmx.org/reference/attributes/
- [S3]
hx-swapreference — https://four.htmx.org/attributes/hx-swap/ - [S4]
hx-valsreference — https://four.htmx.org/attributes/hx-vals/ - [S5]
hx-swap-oobreference — https://four.htmx.org/attributes/hx-swap-oob/ - [S6]
hx-boostreference — https://four.htmx.org/attributes/hx-boost/ - [S7]
hx-triggerreference — https://four.htmx.org/attributes/hx-trigger/ - [S8]
hx-sseextension docs — https://four.htmx.org/extensions/sse/ - [S9] htmx 4.0.0 source,
htmx.org@4/dist/htmx.js(unpkg), line numbers below - [S10] hx-sse extension source,
htmx.org@4/dist/ext/hx-sse.js(unpkg)
Nothing here is from memory; every claim is either quoted from the htmx 4 docs or read out of the shipped 4.0.0 source.
What is actually in use¶
Swept with git grep -o -E 'hx-[a-z-]+(:[a-z]+)?=' migrate-to-htmx4 -- shared_calendar/templates:
| attribute | count |
|---|---|
hx-swap |
30 |
hx-target |
18 |
hx-get |
14 |
hx-post |
6 |
hx-trigger |
2 |
hx-sse:connect |
2 |
hx-push-url |
2 |
hx-vals |
1 |
hx-swap-oob |
1 |
hx-boost |
1 |
hx-boost:inherited |
1 |
Distinct value patterns:
hx-swap:innerHTML(16) andinnerHTML transition:true(14).hx-target:#dialog-container(16),#wishlist-list(2). Nothis/closest/ extended selectors, no selectors containing spaces or commas.hx-get/hx-post: plain{% url %}values; one carries a query string (…?kind={{ value }}).hx-push-url: a URL path (nottrue).hx-vals:js:{kind: new URLSearchParams(location.search).get("kind") || "all"}(items/wishlist/wishlist_page.html).hx-swap-oob:outerHTML:.filter-chips(items/wishlist/partials/_kind_filter.html).hx-boost:falseon the skip-link;hx-boost:inherited="true"on<body>.hx-trigger:update from:body, on the same two elements that carryhx-sse:connect.
Verdict per attribute¶
| attribute | verdict | why / citation |
|---|---|---|
hx-get |
unchanged | Still a first-class verb attribute: #verbs = ["get","post","put","patch","delete","query"] and #actionSelector = '[hx-action],[hx-get],…' [S9 L142, L152]; listed in the 4 reference as "issues GET request to specified URL" [S2]. hx-action/hx-method are additions, not replacements [S1]. |
hx-post |
unchanged | Same as above [S2][S9 L142]. |
hx-target |
unchanged | Reference: "controls where response is inserted" [S2]; resolution is #resolveTarget [S9 L450-460]. Caveat (not hit here): selectors containing spaces or commas must now be quoted [S7]. All our values are bare IDs. |
hx-swap (innerHTML) |
unchanged | innerHTML is still the style name and still the default [S3]. |
hx-swap (innerHTML transition:true) |
unchanged | The modifier syntax did not move. #parseSwapSpec takes the first token as the style and parses the rest as HCON modifiers [S9 L1129-1136], and a task with swapSpec.transition truthy goes through the View Transition queue: if (task.swapSpec?.transition ?? mainSwap?.transition ?? ctx.transition) [S9 L1277]. Docs list transition as a modifier with exactly the transition:true spelling [S3]. What did change in 4 is scroll/show: the combined selector:position form is gone, use show:top showTarget:#other [S1]. We use neither. |
hx-push-url |
unchanged | Read straight off the source element into the request context [S9 L393] and a non-true value is used as the path verbatim [S9 L1663-1676]. Note the related change: history no longer caches pages in localStorage, so back-navigation re-fetches [S1]. That is a behaviour change in history, not in the attribute. |
hx-vals with js: |
unchanged (still supported) | "Use js: prefix to evaluate JavaScript" [S4]. In source, #getAttributeObject calls #extractJavascriptContent, which strips js: (or javascript:) [S9 L1856-1874, L1977-1984], and evaluates it with new AsyncFunction [S9 L896-910]. Two things are new: (1) evaluation is async and awaited (if (valsResult) await valsResult) [S9 L487-491], and (2) inheritance is explicit — a child's hx-vals replaces a parent's per key unless :append is used [S4]. hx-vars is the attribute that was removed; its replacement is exactly this js:-prefixed hx-vals [S1]. allowEval was removed as a config option [S1], i.e. js: can no longer be globally disabled — it is not gated off. |
hx-swap-oob="outerHTML:.filter-chips" |
unchanged for this value; semantics changed around it | The swap-style:selector form is still supported: "any valid hx-swap value, followed by a colon, followed by a CSS selector" [S5]. Source: the colon split only happens when the value has no spaces — if (oobValue !== 'true' && oobValue && !oobValue.includes(' ')) [oobValue, targetSelector] = oobValue.split(/:(.*)/) [S9 L1080-1083]. outerHTML:.filter-chips qualifies. Wrapper stripping is the new bit: swapSpec.strip ??= !swapSpec.style.startsWith('outer') [S9 L1087], i.e. for non-outer* styles "the encapsulating tag pair will be stripped" [S5]. Because we use outerHTML, the wrapper is preserved — no change for us. Two other 4 changes: OOB swap order is reversed (main content swaps before OOB) [S1][S9 L1267 tasks.unshift(mainSwap)], and a response containing only OOB content no longer performs a main swap unless allowEmptySwapAfterOOB/swapEmpty:true [S5][S9 L202, L1262]. Our OOB target (.filter-chips) is disjoint from our main targets, so neither bites. <hx-partial> (<template hx type="partial"> [S9 L1120+]) is offered as an alternative, not a replacement [S5]. |
hx-boost:inherited="true" |
new spelling, correct | "Use hx-boost:inherited to pass that config down to all boosted descendants" [S6]; explicit inheritance via the :inherited modifier is the htmx 4 rule [S1], resolved in #attributeValue [S9 L285-317]. |
hx-boost="false" (opt-out) |
still opts out | Docs: "Selectively disable boost on child elements with hx-boost=\"false\"" [S6]. Source: #attributeValue reads the element's own hx-boost before walking up to an ancestor's hx-boost:inherited [S9 L290-291], and #maybeBoost bails on the literal string: if (hxBoost && hxBoost !== "false" && …) [S9 L948-949]. So the skip-link is not boosted. No edit needed. |
hx-trigger="update from:body" |
semantics changed in context | hx-trigger itself is unchanged as an attribute [S2][S7] (breaking changes that do not affect us: queue: removed in favour of hx-sync, and selectors with spaces/commas must be quoted [S1][S7]). What changed is what it means on an element that also carries hx-sse:connect: in htmx 4 the sse extension uses that element's hx-trigger to decide when to open the connection, defaulting to load — "hx-trigger="load"" is listed under hx-sse:connect defaults [S8], and the source reads let hxTrigger = api.attributeValue(element, 'hx-trigger') || 'load' then opens the stream on that trigger [S10 L330-339]. See the finding below. |
hx-sse:connect |
new (correct) spelling | hx-sse:connect / hx-sse:close are the htmx 4 attributes; the 2.x sse-connect/sse-close are deprecated and sse-swap is removed [S8][S10 L364-372]. |
Finding: the SSE element does double duty and deadlocks¶
items/wishlist/wishlist_page.html and items/calendar/events_page.html both put, on
one element:
<div id="wishlist-list"
hx-sse:connect="/items/stream/"
hx-trigger="update from:body"
hx-get="{% url 'wishlist_list_partial' %}"
hx-vals='js:{…}'
hx-swap="innerHTML">
Per [S8] and [S10 L330-339], hx-trigger on an hx-sse:connect element is the trigger
for opening the stream (default load). Overriding it with update from:body means
the connection is only opened once an update event has already been seen — and the only
source of update events is the stream itself. The stream therefore never connects.
The docs' own pattern keeps the two jobs on separate elements: the connecting element
takes the default load trigger, and a separate element listens for the named event —
"A named event can also trigger another htmx request: <div hx-get="/status"
hx-trigger="progress from:body"></div>" [S8].
from:body itself is fine: named events are dispatched on the request source element
(htmx.trigger(element, detail.message.event, …) [S10 L287]) and bubble — "The event
bubbles from the request source" [S8].
Secondary effect: hx-vals on the connecting element is applied to the connection GET
("The connection uses: hx-headers and hx-vals for the GET request" [S8]), which is
not what the kind filter value is for.
This overlaps with issue #6's SSE claims; flagged here because it is a hx-trigger
semantics change, not just an extension question.
Also relevant (not an attribute, but it changes what the attributes do)¶
- 4xx/5xx now swap by default. Only
204and304are excluded —noSwap: [204, 304][S9 L202], "Previously, 4xx and 5xx responses didn't swap content. Now they do" [S1]. So the repo's pattern of re-rendering a sheet partial withstatus=400swaps natively, and the deletedhtmx:beforeSwapforcing handler is genuinely redundant. Per-status control is nowhx-status:422="…"[S1][S9 L2252-2267]. HX-Triggerresponse header is still handled natively (#handleTriggerHeader[S9 L621, L859]), so the mutating views'204 + HX-Trigger: closeDialogcontract is intact and the deleted JS listener is redundant too.- 60s default request timeout (
defaultTimeout: 60000[S9 L196]) — previously no timeout. Relevant to long-lived SSE requests; worth watching, though the sse extension manages its own connection lifecycle.
Concrete edits this repo needs¶
shared_calendar/templates/items/wishlist/wishlist_page.htmlandshared_calendar/templates/items/calendar/events_page.html— split the SSE connection from the refresh. Keephx-sse:connecton its own element with the defaultloadtrigger, and leavehx-get+hx-trigger="update from:body"+hx-valson the list element. e.g.
<div hx-sse:connect="/items/stream/"></div>
<div id="wishlist-list"
hx-get="{% url 'wishlist_list_partial' %}"
hx-trigger="update from:body"
hx-vals='js:{kind: …}'
hx-swap="innerHTML">…</div>
-
Nothing else.
hx-get,hx-post,hx-target,hx-swap(both value patterns, includingtransition:true),hx-push-url,hx-valswithjs:,hx-swap-oobwithouterHTML:.filter-chips,hx-boost:inherited="true"and the skip-link'shx-boost="false"are all correct as written onmigrate-to-htmx4. -
Optional / hygiene, not required by htmx 4:
- Run
npx htmx.org@4.0.0 upgrade-check -- .as a second opinion; the htmx docs ship it as the official sweep tool. CLAUDE.mdstill documents the 4xx swap-forcing workaround and nameshtmx-ext-sse; both are now wrong (see map #5's docs item).
Could not determine¶
- Whether the calendar/wishlist SSE elements, once split, should share one connecting
element in
base.htmlor keep one per page — a design question, not a docs question. - Whether
defaultTimeout: 60000applies to the sse extension's streaming request in practice; the extension's own reconnect config suggests not, but neither the docs nor a quick read of the source settles it. Needs a runtime check.