A swap that moves the address bar
2026-09-21. The first application outside apps/ on the layer — unotes,
a reader over a private notes corpus, the application-layer soak — has a
filter form and a list of links, all of them swaps. Its log's third
finding: a filter that swaps without changing the URL cannot be reloaded,
bookmarked or sent to anyone, and for a research tool that is the feature.
The scaffold's AGENTS.md says never to type an hx- swap attribute by
hand; that app typed one, hx-push-url="true", on every link and on the
form. This is the lift. SPEC N37 is the row and D46 the decision.
What was built
push=True on the three places a swap is written — Fragment.swap,
Fragment.el and Html.swap[V] — and one defaulted method on
Vocabulary:
f.el("a", "get", url, attr("href", url), text(title), push=True)
_swap[V], the one function every swap goes through, writes the swap and
then calls V.push_url(h) on the same open element. Htmx answers with
hx-push-url="true". The trait's default REFUSES, so a conformance an
application wrote before today still compiles and raises if it is asked
for a push it has no spelling for.
Two refusals
Only a get is pushed, and the check is the layer's, beside the verb
check, so no conformance can forget it. A pushed URL is one the browser
GETs on reload, from a bookmark and on a history restore. Pushing a
post's URL writes an address that answers 405, or one that answers.
htmx 4's query is refused with the writes: its parameters travel in the
body, so its URL alone does not name the view.
Datastar refuses a push. Datastar's free bundle was read for it and
names neither pushState, replaceState nor popstate: it has no history
handling of any kind. Re-read at v1.0.4 (2026-09-21) with the same result,
and the reason is now visible: the two attributes that would spell one,
data-replace-url and data-query-string, are Pro features. So the
condition that would retire this is a pricing decision, not a release. The only spelling would be a history.pushState(...)
appended to the action expression, and nothing would answer the back
button — the address would change back and the page would not. The note on
the one swap mode argues the same way from the other side: a parameter on
swap is a promise both vocabularies keep or one of them breaks silently.
Here the break would not be silent if it raises, so it raises, and the
error names the way out (a plain link, for a view that needs an address).
The refusal is a raise and not a no-op because the failure this piece
exists to stop is exactly a filter that quietly stopped being linkable.
The other half was already there
A pushed address is only real if asking for it rebuilds the view. htmx 4
re-requests a pushed URL on back and forward with HX-Request-Type: full
and no HX-Request, and page_or_fragment has answered that as a
document since N22. So the push needed nothing from the server. The wire
gate asserts both halves together anyway: smoke-fragment-notes reads
hx-push-url off the list's link (expression tier) and the detail's link
back (builder), checks nothing else in the list pushes, then requests the
pushed URL as a restore and insists on the note inside a document. Each
push reverted alone fails the gate at its own line.
In a browser: unotes carries the same bytes, hand-typed, against htmx
4.0.0 in Chrome — the URL moves, back restores the list AND the filter
form's values.
Not built
hx-replace-url. No application has asked.- A tidy pushed URL for a form. A GET form sends every field, so the
pushed URL is
/notes?q=football&era=&type=.Query(N36) writes only what is set for a link the application renders; what the browser serializes from a form is the browser's. - Response-side pushes (
HX-Push-Url). D17 stands: three applications writing the same header setter is its condition, and this is none.