Skip to content

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)

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) and innerHTML transition:true (14).
  • hx-target: #dialog-container (16), #wishlist-list (2). No this/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 (not true).
  • 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: false on the skip-link; hx-boost:inherited="true" on <body>.
  • hx-trigger: update from:body, on the same two elements that carry hx-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 204 and 304 are 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 with status=400 swaps natively, and the deleted htmx:beforeSwap forcing handler is genuinely redundant. Per-status control is now hx-status:422="…" [S1][S9 L2252-2267].
  • HX-Trigger response header is still handled natively (#handleTriggerHeader [S9 L621, L859]), so the mutating views' 204 + HX-Trigger: closeDialog contract 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

  1. shared_calendar/templates/items/wishlist/wishlist_page.html and shared_calendar/templates/items/calendar/events_page.html — split the SSE connection from the refresh. Keep hx-sse:connect on its own element with the default load trigger, and leave hx-get + hx-trigger="update from:body" + hx-vals on 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>
  1. Nothing else. hx-get, hx-post, hx-target, hx-swap (both value patterns, including transition:true), hx-push-url, hx-vals with js:, hx-swap-oob with outerHTML:.filter-chips, hx-boost:inherited="true" and the skip-link's hx-boost="false" are all correct as written on migrate-to-htmx4.

  2. Optional / hygiene, not required by htmx 4:

  3. Run npx htmx.org@4.0.0 upgrade-check -- . as a second opinion; the htmx docs ship it as the official sweep tool.
  4. CLAUDE.md still documents the 4xx swap-forcing workaround and names htmx-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.html or keep one per page — a design question, not a docs question.
  • Whether defaultTimeout: 60000 applies 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.