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 print4.0.0.lateston 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 answer504 (Outdated Optimize Dep)forhtmx__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/.viteafter 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:
- Viewport. The project ran as
Desktop Safari(1280px), butstatic/css/styles.csshides.page-header-mobileand the.fababove 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. - Two strict-mode selectors.
getByLabel("Settings")matches both the page-header icon and the bottom tab;.chip.kind-mealmatches both the desktop and the mobile filter row. Both copies are in the DOM at every viewport, so this is not viewport-dependent. - 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:
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 firedload. On currentmainit fails 3 of 3 with noloadin 12 s. With the Settings module script stripped,loadfires in ~0.5 s. WithgetSubscriptionstubbed, 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, sohtmx:after:requestnever 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.jscontains 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(),readyandgetRegistration()leave it alone, but addingready.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: trueis 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.