Skip to content

HTMX v2 to v4 Migration Guide

This guide covers the breaking changes when upgrading from htmx v2 to v4 in Vibetuner projects. It also documents what changed between htmx 4 pre-release versions (alpha → beta1 → beta3 → beta4 → beta5), so users on any pre-release can migrate.

Quick Start

The two biggest behavioral changes from v2 to v4:

  1. Attribute inheritance is explicit (was implicit in v2)
  2. Error responses (4xx/5xx) swap by default (were skipped in v2)

To restore v2 behavior while you migrate incrementally, add this before loading htmx:

<script>
    htmx.config.implicitInheritance = true;
    htmx.config.noSwap = [204, 304, '4xx', '5xx'];
</script>

Or load the htmx-2-compat extension, which restores implicit inheritance, old event names, and previous error-swapping defaults:

import "htmx.org/dist/ext/htmx-2-compat.js";

Error Response Swapping

htmx 4 swaps all HTTP responses by default. Only 204 and 304 are skipped.

In v2, 4xx and 5xx responses were not swapped. In v4, if your server returns HTML with a 422 or 500, that HTML gets swapped into the target. This means your error responses need to produce valid swap content.

Vibetuner ships the v2 behavior by default. The framework skeleton includes <meta name="htmx-config" content='{"noSwap": [204, 304, "4xx", "5xx"]}'>, so scaffolded apps drop error bodies instead of swapping them. A stray validation 422 never replaces an inline-edit fragment with an error page. Override the htmx_config block in skeleton.html.jinja to change this (keep noSwap unless you handle error swapping another way).

Options:

  • Design error responses as HTML fragments suitable for swapping, then opt a specific element back into swapping with hx-status
  • Use the new hx-status attribute for fine-grained control (it wins over the 4xx/5xx wildcards)
  • The skeleton already reverts globally via noSwap: [204, 304, '4xx', '5xx']

Per-Status-Code Swap Control

The new hx-status attribute lets you control swap behavior per HTTP status code:

<form hx-post="/save"
      hx-status:422="swap:innerHTML target:#errors select:#validation-errors"
      hx-status:5xx="swap:none push:false">
    <!-- form fields -->
</form>

Available config keys: swap:, target:, select:, push:, replace:, transition:.

Supports exact codes (404), single-digit wildcards (50x), and range wildcards (5xx). Evaluated in order of specificity.

Attribute Inheritance Requires :inherited

In v2, many htmx attributes were inherited by child elements automatically. In v4, inheritance must be explicitly opted into using the :inherited modifier.

Before (v2):

<div hx-target="#results">
    <!-- All children inherit hx-target="#results" -->
    <button hx-get="/search">Search</button>
    <button hx-get="/filter">Filter</button>
</div>

After (v4):

<div hx-target:inherited="#results">
    <!-- Children inherit hx-target via :inherited modifier -->
    <button hx-get="/search">Search</button>
    <button hx-get="/filter">Filter</button>
</div>

Without :inherited, each child element must set its own attributes explicitly.

Silent failure: no console error

When a child element has no hx-target or hx-swap of its own, htmx 4 falls back to its defaults (target: the element itself; swap: innerHTML) with no warning. The common <div hx-target="this" hx-swap="outerHTML"> wrapper pattern is the typical casualty — clicking a child button nests the response inside the button instead of replacing the outer fragment. No console error is emitted, and any click handlers on the child stay armed for repeated firing.

Use :append to add to an inherited value instead of replacing it:

<div hx-include:inherited="#global-fields">
    <form hx-include:inherited:append=".extra">...</form>
</div>

Multi-Target Updates with <hx-partial>

<hx-partial> is a new alternative to hx-swap-oob for targeting multiple elements from one response:

<hx-partial hx-target="#messages" hx-swap="beforeend">
    <div>New message</div>
</hx-partial>
<hx-partial hx-target="#count">
    <span>5</span>
</hx-partial>

Each <hx-partial> specifies its own hx-target and hx-swap strategy.

Note

OOB swap order changed in v4: the main content swaps first, then OOB and <hx-partial> elements swap after (in document order). In v2, OOB elements swapped before the main content.

SSE: Native Support Replaces Extension

htmx v4 includes Server-Sent Events support in core. The separate sse extension and hx-ext="sse" attribute are no longer needed, but the attributes changed: sse-connect becomes hx-sse:connect, and sse-swap is removed (there is no equivalent — the extension no longer has its own swap system).

In v4, messages are handled by event name:

  • Named events (the event name in your stream) are dispatched as DOM events on the connecting element. Consume them with hx-trigger="<event> from:#<id>", typically on a hx-get element that re-fetches current state.
  • Unnamed messages (empty event name) are swapped into the connecting element using its own hx-target / hx-swap.

Before (v2):

<div hx-ext="sse" sse-connect="/events/notifications" sse-swap="update">
    <!-- updates appear here -->
</div>

After (v4):

<div id="notifications-stream" hx-sse:connect="/events/notifications"></div>
<div hx-get="/notifications" hx-trigger="update from:#notifications-stream">
    <!-- updates appear here -->
</div>

Backgrounded tabs drop events

hx-sse:connect enables pauseOnBackground by default: a hidden tab closes the stream and reopens it on return, with no replay, so events sent while hidden are lost. Add htmx:after:sse:connection from:#<id> to the consumer's hx-trigger so it re-fetches current state on every reconnect, or disable the pause with hx-config="sse.pauseOnBackground:false". See SSE / Real-Time Streaming.

Warning

The SSE and WebSocket extensions were significantly rewritten for v4. If you use advanced SSE/WS features (custom config, event handling), see the upstream upgrade guides: SSE, WS.

Extension Auto-Registration

In v2, extensions had to be explicitly activated via hx-ext="..." on each element or a parent. In v4, extensions auto-register when imported, no hx-ext attribute needed.

Before (v2):

<body hx-ext="preload">
    <a hx-get="/page" preload="mouseover">Link</a>
</body>

After (v4):

<body>
    <a hx-get="/page" preload="mouseover">Link</a>
</body>

Extensions activate automatically once their script is loaded.

To restrict which extensions can register, use an allow list:

<meta name="htmx-config" content='{"extensions": "preload, sse"}'>

hx-vars Replaced by hx-vals with js: Prefix

The hx-vars attribute (which evaluated JavaScript expressions) has been removed. Use hx-vals with the js: prefix instead.

Before (v2):

<button hx-post="/api/action"
        hx-vars="csrfToken:getCsrfToken(), timestamp:Date.now()">
    Submit
</button>

After (v4):

<button hx-post="/api/action"
        hx-vals='js:{"csrfToken": getCsrfToken(), "timestamp": Date.now()}'>
    Submit
</button>

Note

Plain hx-vals (without js: prefix) still works for static JSON values and is unchanged.

hx-disable Renamed to hx-ignore

The attribute that prevents htmx from processing an element has been renamed. Do this rename before upgrading, because hx-disable means something different in v4 (it now does what hx-disabled-elt used to do).

Rename in this order to avoid conflicts:

  1. Rename hx-disable to hx-ignore
  2. Rename hx-disabled-elt to hx-disable

JavaScript Import Changes

htmx v4 uses a default export. The import pattern in your config.js (or equivalent entry point) must be updated.

Before (v2):

import "htmx.org";

After (v4):

import htmx from "@alltuner/vibetuner/htmx";
window.htmx = htmx;

The default import is required, and you must explicitly assign htmx to window for it to be available globally (e.g., in inline scripts or the browser console).

@alltuner/vibetuner re-exports htmx as a subpath, so scaffolded projects don't need htmx.org as a direct dependency. The bare-specifier import works under any package manager linker mode (Bun hoisted or isolated, pnpm-style stores, npm v9+ isolated).

Preload Extension

The preload extension moved from a separate package to a built-in module.

Before (v2):

import "htmx-ext-preload";

After (v4):

import "@alltuner/vibetuner/htmx/preload";

SSE Extension

The SSE extension also moved from a separate package to a built-in module.

Before (v2):

import "htmx-ext-sse";

After (v4):

import "@alltuner/vibetuner/htmx/sse";

Use hx-sse:connect="/events" on the element that should subscribe to a server-sent events stream (hx-sse:close="<event>" closes it on a named event). There is no hx-sse:swap — named events are dispatched as DOM events and unnamed messages swap into the connecting element; see SSE: Native Support Replaces Extension. The hx-ext="sse" attribute is no longer needed — the extension auto-registers on import.

Event Names Changed from camelCase to Colon-Separated

All htmx event names switched from camelCase to a colon-separated htmx:phase:action format.

Before (v2):

document.addEventListener("htmx:afterRequest", handler);
document.addEventListener("htmx:beforeSwap", handler);
document.addEventListener("htmx:afterSettle", handler);

After (v4):

element.addEventListener("htmx:after:request", handler);
element.addEventListener("htmx:before:swap", handler);
element.addEventListener("htmx:after:swap", handler);

Key renames:

htmx 2.x htmx 4.x
htmx:afterOnLoad htmx:after:init
htmx:afterProcessNode htmx:after:init
htmx:afterRequest htmx:after:request
htmx:afterSettle htmx:after:swap
htmx:afterSwap htmx:after:swap
htmx:beforeRequest htmx:before:request
htmx:beforeSwap htmx:before:swap
htmx:configRequest htmx:config:request
htmx:responseError htmx:response:error (added in beta3)

All error events are consolidated to htmx:error. As of beta3, the specific htmx:response:error event also fires for 4xx/5xx responses, restoring the convenience of htmx 2's htmx:responseError for handlers that only care about HTTP error status codes.

Warning

Events no longer bubble to document.body in v4. You must attach listeners directly to the element or use hx-on attributes. Delegation patterns like document.body.addEventListener('htmx:after:request', ...) do not work.

Event Handler Attributes (hx-on)

The hx-on:: shorthand uses kebab-case event names (DOM attributes are case-insensitive):

<!-- These are equivalent -->
<button hx-get="/info" hx-on:htmx:before-request="alert('Request!')">
<button hx-get="/info" hx-on::before-request="alert('Request!')">

Note

The hx-on:: shorthand was broken in alpha8 but is fixed in beta1. If you are upgrading from alpha8, you can now use the shorter form.

For JSX compatibility, dashes can replace colons:

<button hx-get="/info" hx-on--before-request="alert('Request!')">

Event Detail Structure Changed

The event.detail object was restructured. Properties that existed at the top level are now nested under event.detail.ctx.

Before (v2):

event.detail.successful   // boolean
event.detail.elt          // the triggering element
event.detail.xhr          // XMLHttpRequest object

After (v4):

event.detail.ctx                // context object
event.detail.ctx.sourceElement  // the triggering element
event.detail.ctx.response       // response object
event.detail.ctx.status         // request status string

Danger

event.detail.successful is undefined in v4, which is falsy. Any code checking if(event.detail.successful) silently skips the handler body without errors.

fetch() Replaces XMLHttpRequest

All requests use the native fetch() API. This cannot be reverted. If you have code that interacts with XMLHttpRequest objects (e.g., via event.detail.xhr), it must be updated to use the Response object available at event.detail.ctx.response.

hx-delete Excludes Form Data

Like hx-get, hx-delete no longer includes the enclosing form's inputs. Add hx-include="closest form" where needed.

hx-swap Scroll Modifier Syntax Changed

The show and scroll modifiers no longer support the combined selector:position syntax. Use separate keys:

<!-- v2 (broken in v4) -->
<div hx-swap="innerHTML show:#other:top"></div>

<!-- v4 -->
<div hx-swap="innerHTML show:top showTarget:#other"></div>
<div hx-swap="innerHTML scroll:bottom scrollTarget:#other"></div>

Config Key Renames

Several config keys were renamed:

htmx 2.x htmx 4.x
globalViewTransitions transitions
defaultSwapStyle defaultSwap
historyEnabled history
includeIndicatorStyles includeIndicatorCSS
timeout defaultTimeout

As of beta3, htmx.config.prefix defaults to "data-hx-", so both hx-* and data-hx-* attributes work out of the box (matching htmx 2 behavior). Set to "" to disable the data-prefixed alias. Vibetuner templates use the canonical hx-* form; no change needed.

Changed defaults:

Config htmx 2 htmx 4
defaultTimeout 0 (no timeout) 60000 (60 seconds)
defaultSettleDelay 20 1

Warning

The 60-second default timeout may break long-running requests. If you have endpoints that take longer, set htmx.config.defaultTimeout = 0 or increase the value.

View Transitions

View transitions are disabled by default in htmx 4 beta1 (transitions: false).

Earlier alpha releases (alpha2 through alpha8) had view transitions enabled by default, which caused ~500ms UI blocking after each request (htmx#3566). Vibetuner previously included a <meta> tag to disable them as a workaround. That tag has been removed since beta1 defaults to disabled.

If you had added {"globalViewTransitions": false} to your own templates as a workaround, you can safely remove it. The old config key name is ignored in beta1.

To enable view transitions, set htmx.config.transitions = true and add CSS transition rules per the htmx view transitions docs.

Removed Attributes

Removed Use instead
hx-vars hx-vals with js: prefix
hx-params htmx:config:request event
hx-prompt hx-confirm with js: prefix
hx-ext Include extension script directly
hx-disinherit Not needed (inheritance is explicit)
hx-inherit Not needed (inheritance is explicit)
hx-request hx-config
hx-history Removed (no localStorage in v4)

Note

hx-history-elt was removed in earlier 4.x pre-releases but restored in beta3 alongside an improved hx-history-cache extension. Use it as you did in htmx 2 to mark the element whose inner HTML is captured for history snapshots.

Note

hx-prompt was restored in beta5 as an opt-in hx-prompt extension (it sends the answer in the HX-Prompt request header, like htmx 2). Vibetuner does not ship it by default — see Beta4 to Beta5 Changes.

New Attributes

Attribute Purpose
hx-status Per-status-code swap behavior
hx-action Specify URL (use with hx-method)
hx-method Specify HTTP method
hx-config Per-element request config (replaces hx-request)
hx-ignore Disable htmx processing (replaces hx-disable)
hx-validate Control form validation behavior
hx-morph-skip Skip morphing for the matching element (beta5)
hx-morph-skip-children Morph the element but leave its children untouched (beta5)

New Extensions

htmx 4 ships with these core extensions. All auto-register when imported.

Extension Description
browser-indicator Shows the browser's native loading indicator during requests
optimistic Shows expected content from a template before the server responds
upsert Update-or-insert swap strategy for dynamic lists
download Save responses as file downloads with streaming progress
targets Swap the same response into multiple elements
history-cache Client-side history cache in sessionStorage
ptag Per-element polling tags to skip unchanged content
alpine-compat Alpine.js compatibility
htmx-2-compat Backward compatibility layer for htmx 2.x code
csp CSP nonce-based protection for inline scripts and eval-style code paths (beta3, renamed from nonce in beta4)
live DOM-reactivity via hx-live and richer hx-on helpers: q(), toggle(), debounce() (beta3)
hx-prompt Restores htmx 2's hx-prompt attribute: prompts before the request and sends the answer in the HX-Prompt header (beta5, opt-in)

htmx also provides an htmax bundle (htmax.min.js) that includes htmx plus the most popular extensions (SSE, WebSockets, preload, browser-indicator, download, optimistic, targets) in a single file.

JavaScript API Changes

Removed methods (use native JS):

htmx 2.x Use instead
htmx.addClass() element.classList.add()
htmx.removeClass() element.classList.remove()
htmx.toggleClass() element.classList.toggle()
htmx.closest() element.closest()
htmx.remove() element.remove()
htmx.off() removeEventListener()
htmx.location() htmx.ajax()
htmx.logAll() htmx.config.logAll = true

Renamed: htmx.defineExtension() is now htmx.registerExtension().

Alpha to Beta1 Changes

If you are upgrading from an htmx 4 alpha release (not from v2), here is what changed specifically between the alpha series and beta1:

  • hx-on:: shorthand fixed: The double-colon shorthand (e.g., hx-on::before-request) was broken in alpha8. It works correctly in beta1.
  • globalViewTransitions config removed: Renamed to transitions. The old key is silently ignored. Remove any <meta> tags using the old name.
  • View transitions disabled by default: No longer need the {"globalViewTransitions": false} workaround that was required in alpha2-alpha8.
  • SSE/WS extensions rewritten: New APIs with per-element config, exponential backoff, HX-Request-ID correlation. See the upstream SSE and WS upgrade guides.
  • New extensions added: history-cache, ptag, targets, download.
  • htmax bundle available: Single-file bundle with htmx + popular extensions.

Beta1 to Beta3 Changes

If you are upgrading from htmx 4 beta1 or beta2 (not from v2), here are the changes specific to beta3 (which is the 4.0 release candidate).

New Extensions

  • hx-nonce: CSP nonce-based protection for inline scripts and eval-style code paths. Blocks elements without a matching hx-nonce attribute, and defends against js:/javascript: action URLs and unnonced boosted-form submitters. Vibetuner enforces nonce-based CSP by default, and (under its renamed hx-csp form) loads this extension by default — it is required for hx-on: / hx-live to work under the strict script-src. See the vibetuner CSP docs. Renamed to hx-csp in beta4 — see the Beta3 to Beta4 Changes section below.
  • hx-live: DOM-reactivity via hx-live="..." (a JS expression re-evaluated whenever any DOM input/change/mutation event fires) plus a richer JavaScript surface inside hx-on: q(selector) jQuery-like proxy, sigil-syntax toggle('@attr') / toggle('*display=none|block'), per-element debounce(ms[, fn]), and htmx.live.q / htmx.live.take(target, className, source) outside expression scope. Default-on in vibetuner as of @alltuner/vibetuner 10.15.0 — see Live Reactivity below.

New Swap Style: outerSync

<div hx-swap="outerSync"></div>

Copies attributes onto the existing target and replaces children. Useful for clean <body> swaps in history replacement where you want to update the body's attributes without losing the element identity.

Restored Attribute: hx-history-elt

hx-history-elt is back. Mark the element whose inner HTML is captured for history snapshots, the same as in htmx 2.

Behavior Changes

  • htmx.config.prefix defaults to "data-hx-": Both hx-* and data-hx-* work out of the box, matching htmx 2 behavior. Set to "" to disable the data-prefixed alias.
  • htmx:response:error event added: Fires for HTTP 4xx/5xx responses, restoring the convenience of htmx 2's htmx:responseError.
  • hx-download auto-detection: The hx-download extension now auto-detects downloads via the Content-Disposition response header, so you no longer need an hx-download attribute on each triggering element.
  • hx-preload boost knobs: Added boostEvent, boostTimeout, and autoBoost config keys for tighter integration with hx-boost.

Security Hardening

  • hx-config no longer accepts request mode overrides: Removes a privilege-escalation surface where a swap could downgrade origin enforcement.
  • Constructable stylesheet for indicator CSS: The runtime indicator CSS now uses a CSSStyleSheet constructor instead of an injected <style> tag, avoiding CSP unsafe-inline violations on style-src. With this change, CSP-strict deployments can drop 'unsafe-inline' from style-src (Vibetuner is moving in this direction).
  • Pantry element switched from inline style to hidden: Resolves another CSP unsafe-inline violation.

Breaking JavaScript API Changes

htmx.takeClass() and htmx.forEvent() moved out of htmx core into the new hx-live extension and are exposed via the htmx.live namespace (e.g. htmx.live.take(target, className, source)). If you were calling them directly, import the hx-live extension or migrate to native equivalents. Vibetuner loads hx-live by default, so htmx.live.take(...) is available without extra setup.

Beta3 to Beta4 Changes

Beta4 is a small follow-up to the 4.0 release candidate. The only vibetuner-relevant change is the rename of the CSP extension.

Upgrade Recipe

For most projects this is a three-step bump:

  1. Bump @alltuner/vibetuner in package.json to a release that ships [email protected] (or run just deps-scaffolding-pr / just deps-scaffolding).
  2. Run bun install to refresh node_modules and bun.lock.
  3. If your config.js imports the CSP extension, update the path from hx-nonce.js to hx-csp.js (see below).

Run just lint and just dev to confirm the build still passes; no template changes are required.

hx-nonce Extension Renamed to hx-csp

The extension file that beta3 shipped as ./node_modules/htmx.org/dist/ext/hx-nonce.js is now ./node_modules/htmx.org/dist/ext/hx-csp.js in beta4. The HTML attribute the extension reads is still named hx-nonce — only the extension itself was renamed (its scope grew beyond pure nonce gating, so the name was generalised to "CSP").

If you imported the extension in config.js:

// Before (beta3):
import "./node_modules/htmx.org/dist/ext/hx-nonce.js";

// After (beta4):
import "./node_modules/htmx.org/dist/ext/hx-csp.js";

If you registered the extension via hx-ext (uncommon — most projects let vibetuner register it via the import above):

<!-- Before (beta3): -->
<meta name="htmx-config" content='extensions:"hx-nonce"'>

<!-- After (beta4): -->
<meta name="htmx-config" content='extensions:"hx-csp"'>

Template hx-nonce="{{ csp_nonce }}" attributes need no change.

Other Beta4 Changes (no action required)

Beta4 also ships several bug fixes and minor additions that do not require any template or code changes in vibetuner projects:

  • New hx-on / hx-trigger modifiers (prevent, stop, halt, capture, passive, from:self, from:outside) with a unified arrow grammar: hx-on="event mods -> code". Vibetuner templates use plain hx-on:event="..." syntax which keeps working — adopt the arrow form opportunistically when a modifier set would otherwise duplicate code.
  • rootMargin modifier on the intersect trigger.
  • Fixes for hx-get / hx-delete over non-form inputs (these variants no longer gather unrelated form fields when triggered on bare inputs).
  • outerSync now re-processes the correct body on history restore.
  • hx-ws with hx-trigger="load" waits for the socket to open instead of erroring.

If you had been relying on the undocumented dot-modifier shorthand (hx-on:click.prevent="..."), it has been removed — use the new arrow grammar (hx-on="click prevent -> ...") instead. Vibetuner templates do not use this shorthand, so a clean codebase needs no migration here.

The hx-trigger="..." queue:..." modifier (a no-op since 4.0) was also hard-removed in beta4; use hx-sync="this:queue all" instead. Vibetuner templates do not use the queue: modifier either.

Beta4 to Beta5 Changes

Beta5 is another small follow-up to the 4.0 release candidate. Nothing in it breaks a clean htmx 4 codebase, and Vibetuner's templates need no changes. The notable additions are a restored hx-prompt, a formalized config grammar (HCON), and an SSE-friendly empty-swap default.

Upgrade Recipe

For most projects this is a two-step bump:

  1. Bump @alltuner/vibetuner in package.json to a release that ships [email protected] (or run just deps-scaffolding-pr / just deps-scaffolding).
  2. Run bun install to refresh node_modules and bun.lock.

Run just lint and just dev to confirm the build still passes; no template changes are required.

HCON: the config-attribute grammar is formalized

htmx 4's config attributes (hx-trigger, hx-swap, hx-vals, hx-config, hx-headers, and the <meta name="htmx-config"> tag) now parse through a single documented mini-language, HCON. It is a backward-compatible superset of what beta4 accepted:

  • A value starting with { is still parsed as JSON, so JSON hx-vals and <meta> config keep working unchanged.
  • Space- or comma-separated key:value pairs work as before, plus flag-style booleans (key alone means true), dotted nesting (sse.reconnect:true), and single- or double-quoted values for spaces/commas.
  • The js: prefix on hx-vals / hx-confirm is unaffected — it is still evaluated as JavaScript, not parsed as HCON.

No action required: Vibetuner's templates don't use these config attributes, and any existing JSON or modifier strings parse identically.

hx-prompt restored as an opt-in extension

Beta5 adds an hx-prompt extension that brings back htmx 2's hx-prompt attribute: it prompts the user before the request and sends the answer in the HX-Prompt request header (read it via request.state.htmx.prompt). Vibetuner does not load it by default; add it to your config.js if you want it:

import "htmx.org/dist/ext/hx-prompt.js";

swapEmpty and the SSE empty-response default

A new swapEmpty swap modifier and htmx.config.defaultSwapEmpty control whether an empty response still swaps. SSE now defaults to not swapping empty responses, which is the behavior Vibetuner's streams already want — empty keepalive frames no longer blank out the connected element. Override per element with hx-swap="innerHTML swapEmpty:true" if you need the old behavior.

Morph skip attributes

hx-morph-skip and hx-morph-skip-children mark elements the morph swap should leave alone (the whole element, or just its children). They default to the [hx-morph-skip] / [hx-morph-skip-children] selectors. Only relevant if you use a morph swap.

hx-live expansion

The hx-live extension gained declarative bindings, a reactive engine, a JSON data proxy, and xpath + Alpine conflict handling. One behavior change: the take helper now defaults to sibling scope. Vibetuner's documented hx-live patterns (see Live Reactivity) are unaffected, but review any custom htmx.live.take(...) calls that relied on the previous default scope.

Other beta5 changes (no action required)

  • ctx (the request context) is now passed to hx-confirm, hx-vals, and hx-headers JavaScript expressions.
  • Download links (<a download>) are no longer boosted.
  • hx-encode falls back to the enclosing form's enctype.
  • The hx-csp extension's nonce rewriting now handles unquoted nonce attributes — relevant to Vibetuner's default-on CSP, and a pure improvement.
  • Click modifiers pass through only on links; hx-preload uses passive event listeners; the optimistic extension supports live content.

Framework-side changes in Vibetuner

Landing on beta5, Vibetuner also brought its server-side htmx surface in line with htmx 4:

  • request.state.htmx now exposes the htmx 4 request headers — .source (HX-Source, the renamed request-side HX-Trigger) and .request_type (HX-Request-Type). The htmx 2 .trigger / .trigger_name properties are gone.
  • The hx_trigger_after_settle and hx_trigger_after_swap response helpers were removed — htmx 4 dropped the HX-Trigger-After-Settle / HX-Trigger-After-Swap headers they set. Use hx_trigger (the HX-Trigger response header is unchanged).

Live Reactivity with hx-live

@alltuner/vibetuner 10.15.0 imports hx-live by default from config.js (in the framework-managed block, alongside hx-preload). This is vibetuner's recommended path for client-side reactivity — chip lists, derived form fields, live filters, paired controls. Reach for it before adding Alpine.js, Stimulus, or hand-rolled event-listener boilerplate.

Why it fits vibetuner

Vibetuner's CSP runs script-src 'nonce-X' 'strict-dynamic' with no 'unsafe-inline' and no 'unsafe-eval', which blocks inline onclick="..." / onchange="..." attributes at the spec level, and would also reject htmx's own new Function() evaluation of hx-on: / hx-live expressions with an EvalError. The default-on hx-csp extension (formerly hx-nonce, see Beta3 to Beta4 Changes) is what makes them work: with safeEval on, htmx evaluates those expressions via nonce-based <script> injection, which the nonce + strict-dynamic CSP does permit. So hx-on: and hx-live are genuinely CSP-safe without 'unsafe-eval', where raw handler attributes are not. hx-csp is loaded by default (see htmx CSP Protection), so no extra wiring is required.

Idiomatic patterns

Derive a hidden field from a chip list (the OAuth scope editor in debug/oauth_app_form.html.jinja uses this exact shape):

<div id="scopes-tags"
     hx-on:click="
       const btn = event.target.closest('button[data-action=remove-scope]');
       if (btn) btn.closest('.badge').remove();
     ">
  {% for scope in scopes %}
    <span class="badge" data-scope="{{ scope }}">
      <span data-text>{{ scope }}</span>
      <button type="button" data-action="remove-scope">×</button>
    </span>
  {% endfor %}
</div>
<input type="hidden" name="scopes"
       hx-live="this.value = q('#scopes-tags .badge').arr()
                             .map(b => b.dataset.scope).join(',')">

The hidden input recomputes its value on every mutation under #scopes-tags — add and remove both stay in sync without manual wiring. Event delegation on the container handles remove clicks on both initially-rendered and dynamically-inserted badges (q().insert() is raw insertAdjacentHTML — htmx does not re-process inserted nodes).

Conditional CSS class from a sibling input:

<input id="age" type="number" value="0">
<p hx-live="this.classList.toggle('text-error',
                                  q('#age').valueAsNumber < 18)">
  Adult content
</p>

Debounced live search:

<input id="q" placeholder="search">
<output hx-live="
  let term = q('#q').value;
  if (!term) { this.textContent = ''; return; }
  await debounce(250);
  this.textContent = await fetch('/search?q=' +
    encodeURIComponent(term)).then(r => r.text());
"></output>

The await debounce(250) is per-element — successive keystrokes cancel the in-flight call via async rejection, so only the final term hits the server.

Rough edges to know

  • The DOM is the only source of truth. No JS-variable reactivity. Share state via data-* attributes or hidden inputs. Alpine.js refugees will expect refs — hx-live deliberately doesn't have them.
  • Expressions re-run on any DOM mutation. Cheap by default, but unconditional side effects (a bare fetch(), mutating the DOM tree the expression reads) will tank performance or trip the >50/s self-mutation cutoff (the expression deactivates with a console warning). Guard with debounce or a value-change check.
  • Set-property writes broadcast silently. q('.field').value = '' writes to every matching element. A selector that accidentally widens (e.g. an :inherited attribute unexpectedly inheriting) clobbers things you didn't intend. Prefer narrow selectors and add data-* markers where ambiguity is possible.
  • next / prev / closest anchor to this. Inside hx-live they mean "relative to the owner element". Calling htmx.live.q('next .foo') from a free-floating script is undefined.
  • q().insert(pos, html) does not run htmx.process() on the inserted markup. Dynamic hx-on: / hx-live attributes won't be wired. Use event delegation on a stable parent (as in the chip-list pattern above) or call htmx.process(elt) after insertion.
  • hx-config no longer accepts request mode overrides in beta3 (security fix). Unrelated to hx-live itself but ships together — drop hx-config="mode:..." attributes if you have them.

Common Migration Issues

SSE elements stop updating after upgrade

Symptom: SSE-powered elements no longer receive updates.

Cause: The hx-ext="sse" attribute was removed but the SSE extension script is still being loaded, conflicting with htmx v4's built-in SSE support.

Fix: Remove both the hx-ext="sse" attribute and any <script> tag loading htmx-ext-sse. Rename sse-connect to hx-sse:connect; sse-swap is removed, so replace it per the SSE migration above.

window.htmx is undefined in inline scripts

Symptom: Inline <script> tags or browser console show htmx is not defined.

Cause: htmx v4 uses a default export that must be explicitly assigned to window.

Fix: Update your JS entry point:

import htmx from "@alltuner/vibetuner/htmx";
window.htmx = htmx;

Attributes no longer inherited by child elements

Symptom: A child element's request silently targets itself with innerHTML swap instead of using the ancestor's hx-target/hx-swap. The classic failure is a <div hx-target="this" hx-swap="outerHTML"> wrapper: clicking a child button nests the server response inside the button rather than replacing the outer fragment. No console error is emitted, and click handlers on the child remain armed.

Cause: htmx v4 no longer inherits attributes from ancestors by default. Every element must carry its own hx-target, hx-swap, hx-trigger, etc.

Fix (each element carries its own attributes):

<!-- v2: child inherits hx-target/hx-swap from parent -->
<div hx-target="this" hx-swap="outerHTML">
    <button hx-get="/fragment">Refresh</button>
</div>

<!-- v4: child declares its own attributes -->
<div>
    <button hx-get="/fragment"
            hx-target="closest div"
            hx-swap="outerHTML">Refresh</button>
</div>

Fix (keep the wrapper — add :inherited to parent attributes):

<div hx-target:inherited="this" hx-swap:inherited="outerHTML">
    <button hx-get="/fragment">Refresh</button>
</div>

Preload extension not working

Symptom: preload="mouseover" has no effect after upgrade.

Cause: The import path changed and the old hx-ext="preload" is no longer needed.

Fix: Update your import and remove hx-ext:

// Before: import "htmx-ext-preload";
import "@alltuner/vibetuner/htmx/preload";
<!-- Before: <body hx-ext="preload"> -->
<body>

hx-vars attribute ignored

Symptom: Dynamic values previously set via hx-vars are no longer sent.

Cause: hx-vars was removed in v4.

Fix: Use hx-vals with the js: prefix:

<!-- Before: hx-vars="token:getToken()" -->
hx-vals='js:{"token": getToken()}'

Error responses replacing page content unexpectedly

Symptom: A 422 or 500 response swaps error HTML into the page where it previously was ignored.

Cause: htmx 4 swaps all HTTP responses by default.

Fix: Use hx-status for per-element control, or revert globally:

htmx.config.noSwap = [204, 304, '4xx', '5xx'];

Long-running requests timing out

Symptom: Requests that worked in v2 now fail after 60 seconds.

Cause: htmx 4 defaults to a 60-second timeout (v2 had no timeout).

Fix: Increase or disable the timeout:

htmx.config.defaultTimeout = 0; // no timeout, like v2

Migration Checklist

  • [ ] Replace hx-on:: shorthand with hx-on:htmx: long form, or upgrade to beta1+ where the shorthand works
  • [ ] Replace event.detail.successful with event.detail.ctx.response
  • [ ] Replace camelCase event names with colon-separated (e.g., afterRequestafter:request)
  • [ ] Move document.body event listeners to the element or use hx-on attributes
  • [ ] Remove all hx-ext="sse" attributes from SSE elements
  • [ ] Remove all other hx-ext="..." attributes (extensions auto-register)
  • [ ] Replace hx-vars with hx-vals using js: prefix
  • [ ] Rename hx-disable to hx-ignore, then hx-disabled-elt to hx-disable
  • [ ] Add :inherited modifier to attributes that rely on inheritance
  • [ ] Update JS imports to use default import and window.htmx = htmx
  • [ ] Update preload extension import path
  • [ ] Remove htmx-ext-sse and htmx-ext-preload from package.json if present
  • [ ] Rename config keys (globalViewTransitionstransitions, etc.)
  • [ ] Remove any {"globalViewTransitions": false} meta tags
  • [ ] Test error handling (4xx/5xx now swap by default)
  • [ ] Test long-running requests against the 60-second timeout
  • [ ] Update hx-swap scroll modifiers to new syntax if used

Beta3 to Beta4 Checklist

If you are already on htmx 4.0.0-beta3 (the common case for projects scaffolded since @alltuner/vibetuner 10.11.0), this is the only work that beta4 requires:

  • [ ] Bump @alltuner/vibetuner in package.json to a release that ships [email protected]
  • [ ] Run bun install
  • [ ] If config.js imports the CSP extension, change import "./node_modules/htmx.org/dist/ext/hx-nonce.js"; to import "./node_modules/htmx.org/dist/ext/hx-csp.js";
  • [ ] If any <meta name="htmx-config" content='extensions:"hx-nonce"'> tags exist, change hx-nonce to hx-csp (uncommon — most projects register the extension via the import above)
  • [ ] If you ever used the undocumented dot-modifier shorthand (hx-on:click.prevent="..."), migrate to the new arrow grammar (hx-on="click prevent -> ...") — these shortcuts were removed in beta4
  • [ ] If you used the queue: modifier on hx-trigger (a no-op since 4.0), migrate to hx-sync="this:queue all" — removed in beta4
  • [ ] No template changes required — hx-nonce="{{ csp_nonce }}" attributes keep working

Beta4 to Beta5 Checklist

If you are already on htmx 4.0.0-beta4, this is the only work beta5 requires:

  • [ ] Bump @alltuner/vibetuner in package.json to a release that ships [email protected]
  • [ ] Run bun install
  • [ ] No template changes required — HCON parses existing hx-* config attributes identically, and Vibetuner's templates don't use them anyway
  • [ ] If your server reads the request-side trigger, switch from the htmx 2 HX-Trigger / request.state.htmx.trigger to HX-Source / request.state.htmx.source
  • [ ] If you set the HX-Trigger-After-Settle / HX-Trigger-After-Swap response headers (or used the removed hx_trigger_after_settle / hx_trigger_after_swap helpers), move to HX-Trigger / hx_trigger
  • [ ] (Optional) Add import "htmx.org/dist/ext/hx-prompt.js"; to config.js if you want the restored hx-prompt attribute