Skip to content

htmx 4 migration — runtime findings

What actually happened when the branch was first run. Companion to htmx4-claims.md (issue #6) and htmx4-attribute-audit.md (issue #7), both of which were written without running the app. Covers issues #8, #9, #10, #12, #13.

Date: 2026-09-21. Branch migrate-to-htmx4, htmx 4.0.0 (verified installed, see below).


Verify what installed

npm install on a tree that previously held htmx 2 leaves two stale caches, and both produce failures that look like migration bugs:

  • node_modules/htmx.org — check it, don't assume: node -p "require('./node_modules/htmx.org/package.json').version" must print 4.0.0. latest on npm is still 2.x, which is why the pin is exact.
  • node_modules/.vite — Vite's pre-bundled dep cache. A stale entry makes the dev server answer 504 (Outdated Optimize Dep) for htmx__org_dist_ext_hx-sse.js. The import throws, htmx never loads at all, and every dialog, boost and swap in the app silently stops working. rm -rf node_modules/.vite after switching branches.

The first Playwright run on this branch was entirely invalid for this reason.

Baseline (issue #8)

uv run pytest: 99 passed, before and after every change here. Nothing in the Python suite touches htmx.

Playwright, run four ways:

Run Result Wall time
branch, committed config (Desktop Safari) 9 failed / 8 passed 3.9 min
main, committed config 9 failed / 8 passed 3.3 min
main, viewport 430×900 2 failed / 15 passed 16 s
branch, viewport 430×900, clean Vite cache 7 failed / 10 passed 1.2 min

The suite was already red on main. Three independent causes were tangled together:

  1. Viewport. The project ran as Desktop Safari (1280px), but static/css/styles.css hides .page-header-mobile and the .fab above 900px. The specs are written against the phone layout, so they spent 30 s each timing out on invisible elements. This is why the suite took minutes: it was almost entirely timeouts.
  2. Two strict-mode selectors. getByLabel("Settings") matches both the page-header icon and the bottom tab; .chip.kind-meal matches both the desktop and the mobile filter row. Both copies are in the DOM at every viewport, so this is not viewport-dependent.
  3. The stale Vite cache above.

None of the three is an htmx 4 regression. Fixed on this branch (a mobile viewport in playwright.config.js, and both selectors scoped to main), because otherwise "green" is not available as a finish line.

What was genuinely attributable to htmx 4

Five specs, once the noise was removed. Four of them —wishlist.spec.ts ×3 and validation-error.spec.ts:23 — fail waiting for a list to change after a save. That is exactly the sync-canary property the spec predicted: mutating views return 204 and never return a list, so any assertion that a list changed can only pass if the broadcast arrived. The suite was already a sync test; it just never said so.

The fifth is the Logout spec in settings.spec.ts. It turned out not to be htmx at all; see Resolved blocker below.

validation-error.spec.ts:9 passes on htmx 4 with the workaround deleted. That is live confirmation of Claim 1: 4xx responses swap natively.

Coverage gaps (issue #8)

Of the four silent-failure paths the ticket asked about, the suite covers three incidentally (dialog open via hx-get into #dialog-container; dialog close via HX-Trigger: closeDialog on a 204; the 400 partial swapping into the open sheet) and covered the fourth — SSE refreshing a list — only for the acting client. Nothing tested the app's actual promise: two viewers, one mutates, the other updates. tests/cross-client-sync.spec.ts closes that gap. It was verified to fail — 3 of 3 — against the broken wiring, and pass against the fix.

The SSE fix (issue #10)

Confirmed exactly as diagnosed in htmx4-claims.md §4b, and fixed by splitting the roles:

<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">…</div>

Applied to events_page.html and wishlist_page.html. The wishlist's hx-vals='js:{kind: …}' stays on the list element — on a connecting element it would be applied to the connection request instead.

Nothing else on either page relied on the merged element. Decision: one connecting element per page, not one in base.html — the base template also renders settings and login, which have no list to refresh and no reason to hold a stream open.

Verified live: the connection reaches status: 200 and carries real event ids (lastEventId: "items:4", "items:5"), and all three cross-client specs pass.

The Django side (issue #9)

Nothing changes in Python. Swept shared_calendar/ for every place the server encodes an htmx assumption; there is exactly one:

shared_calendar/items/views.py:34:    response["HX-Trigger"] = "closeDialog"

That is the response header, which htmx 4 still reads under that name. The request header rename (HX-Trigger → HX-Source, HX-Trigger-Name removed) touches nothing, because no view or middleware reads any htmx request header — no HX-Request branch, no HX-Trigger-Name, nothing. Recorded here as the answer rather than left unexamined.

django-eventstream needs no change either: it writes event: update, the extension dispatches the server's event name verbatim as a DOM event, and hx-trigger="update from:body" picks it up. The two stream URLs in shared_calendar/urls.py both serve the "items" channel and are unchanged. _broadcast() in items/services.py (the ticket calls it _fire_sse() in items/views.py; it moved in the services extraction) is untouched.

upgrade-check CLI (issue #12)

npx htmx.org@4.0.0 upgrade-check -- . reports a wall of hits, all of them in staticfiles/ — stale collectstatic output holding the old htmx 2 bundles. That directory is a build artifact and is gitignored; scanning it is noise.

Run against the source that matters:

Path Files scanned Issues
shared_calendar/templates 20 0
frontend 1 0
static 1 0
sw.js 1 0

The reconciliation, which is the point of the ticket: the CLI found nothing the research missed, and missed the only real bug in the migration. It scanned both page templates while they still carried hx-trigger on the hx-sse:connect element — the defect that killed real-time sync — and reported them clean. The tool matches renamed attributes, events, headers and APIs; it does not reason about what an attribute means on the element it sits on. Useful as a rename sweep, worthless as a correctness check. Point it at source paths only, or staticfiles/ will bury the result.

dist/skills/htmx-upgrade-from-htmx2.md (shipped in the tarball; the published htmx.org/migration-guide-htmx-4/ URL 404s) raises nothing the map does not already cover.

Resolved blocker — the Logout spec was Playwright WebKit's Push API (issue #35)

Resolved 2026-09-28; an earlier version of this section had the cause wrong. The stall was not an htmx 4 regression, not SSE, and not an iOS PWA risk. Settings calls pushManager.getSubscription() from static/js/push.js as soon as serviceWorker.ready resolves. In Playwright's WebKit build, the first such call in a context re-navigates the page to its own URL (a navigation request with no beforeunload, and no crash event). The page is often left wedged: load never fires, and evaluate hangs.

That one call explains both symptoms:

  • Logout: the full navigation to /accounts/settings/ never fired load. On current main it fails 3 of 3 with no load in 12 s. With the Settings module script stripped, load fires in ~0.5 s. With getSubscription stubbed, it fires in ~0.2 s.
  • The Settings Back link (navigation.spec.ts, 9 of 10 failures): in a fresh context the service worker activates a few hundred ms after load, right when the spec clicks. The re-navigation throws away the in-flight boosted request, so htmx:after:request never fires and the URL stays on Settings.

How it was isolated:

  • Blocking /sw.js → 10/10 pass.
  • An empty worker → still fails. So what sw.js contains doesn't matter.
  • A worker whose install waits 3 s → 10/10 pass. The failure needs activation to land mid-click.
  • With no click and no fetch at all, Settings still navigates a second time once the worker is ready.
  • On a bare page with an empty worker: register(), ready and getRegistration() leave it alone, but adding ready.then(r => r.pushManager.getSubscription()) reproduces the second navigation. The promise never settles.
  • Vite is ruled out: its socket received no message and never closed.

The fix is in the tests. tests/fixtures.ts stubs PushManager.getSubscription to resolve null in every context, and every spec imports test from it. No spec exercises push. With it, Logout's fixme is lifted.

Lifting it exposed a real layout bug. On a phone, nothing reserved room for the fixed bottom tab bar, so Settings' Log out button sat half behind it even fully scrolled. It is fixed in static/css/styles.css, with desktop.css resetting the padding where the bar is hidden.

Not reconciled: the claim above that the same flow passed on htmx 2. Settings made the same getSubscription() call before the upgrade (cc3d06f^), so that measurement must have differed in some other way: the Playwright/WebKit build, or timing. It wasn't re-run. Whether a real device's first getSubscription() has any cost was not tested. The stall needed nothing but that call, though, so it's no evidence about SSE on an iOS PWA.

View-transition direction

The branch had moved the direction tagging from htmx:beforeTransition to htmx:before:swap, on the stated grounds that htmx 4's htmx:before:viewTransition "only carries the transition task (no source element)". That is wrong: htmx 4 fires it with {task, ctx} on ctx.sourceElement (dist/htmx.js, #processTransitionQueue), so detail.ctx.sourceElement is available and the handler never needed to move.

Worse, the move introduced a real ordering bug. A swap does not run its transition; it queues it, and the queue is drained one at a time. So tagging at htmx:before:swap lets a later swap overwrite the direction of a transition still waiting its turn — e.g. a wishlist filter chip (data-transition="none", but still transition:true) landing mid tab-slide. Reverted to htmx:before:viewTransition, which fires immediately before each transition starts and restores the htmx 2 timing exactly.

Still open, and the reason the map left this "not yet specified": the popstate listener that tags data-transition="back" looks inert on htmx 4. History restore re-requests the page with swap: 'outerSync' and no transition (dist/htmx.js, the history-restore ajax call), so no view transition starts on back/forward and the back keyframes cannot play. The listener still usefully clears stale direction state, but the backward animation itself is probably not happening. Needs eyes on the real app, which is exactly the prototype ticket the map anticipated.

Runtime checks (issues #11, #13)

#13 — does defaultTimeout: 60000 kill the stream? Answered 2026-09-22: no. The SSE extension clears the timer on takeover, and a stream held open for 7m41s never dropped. No fix needed. Findings, method and two misleading traps in htmx4-sse-timeout.md.

Still not performed:

  • #11 — background pause. pauseOnBackground: true is confirmed live in the connection config. The decision is to keep the htmx 4 default: two phones are backgrounded most of the time, pausing avoids holding dead uvicorn connections, and push notifications are already how a backgrounded device learns something changed. What is not yet verified is the wake path — whether a reopened stream re-fetches the list or comes back stale. Until that is observed on a real device, CLAUDE.md's "changes sync in real-time across devices" claim is left as written.