A Datastar form, end to end — shipped 2026-09-12
A design note from the engineering record. The fourth phase of the application layer's plan: the piece the plan named "the form arm is where a wrong spelling fails silently", and what a browser said about it.
Where this comes from. Fragment[Datastar] had spelled the form arm
since the two-transports round — swap on a <form> emits
data-on:submit__prevent="@post('url', {contentType: 'form'})" — and one
unit test pinned that string. No browser had sent it. The todo demo used
signals for everything (data-bind:draft, a keydown action posting the
store), so the one attribute whose correctness only a browser can judge
was the one attribute no browser had judged. SPEC N12 named the gap and
decision D21 rested on it.
The piece. The todo demo renames a todo in place. Each open todo's text
is now the one field of a <form class="edit">, and swap("post", url_for(EDIT, id)) on that form spells the submit action; done todos stay
<s>text</s>. The new route POST /edit/:id reads form(req): None for
any body that is not application/x-www-form-urlencoded, answered as a
400 problem rather than read as a field named after itself (D13's point,
now on the wire); an empty text is a no-op like an empty draft; anything
else updates the row and broadcasts, so the renamed todo morphs into every
tab through the same patch-elements frame every other mutation uses. The
fragment stays one line, and render_todos stays the only renderer.
The smoke (smoke-todo, every pull request) posts the form the way a
browser would, urlencoded with text=walk+the+dog, and greps the frame
for the renamed value; greps the served page for the exact wire spelling,
data-on:submit__prevent="@post('/edit/2', {contentType: 'form'})", escaped as the builder escapes it; and posts a JSON
body to the same route and insists on the 400 and on the value never
reaching a frame.
The browser run (poe browser-datastar-form, pre-release) starts the
demo, opens two Chromium tabs, adds a todo from the draft field and renames
it from the keyboard in one tab, records every request the bundle makes,
and waits for the other tab to morph. What the pinned bundle (v1.0.3)
sent, verbatim:
the field's action (D21): POST /add content-type 'application/json'
body '{"draft":"buy milk"}'
the form's submit (N12): POST /edit/1 content-type 'application/x-www-form-urlencoded'
body 'text=buy+oat+milk'
Both requests carried Datastar-Request: true. The second tab showed the
renamed text without a reload.
D21 confirmed. The decision said a Datastar field's own action sends
the signal store, never the field, and that a form arm sends the fields.
The run shows exactly that split: the draft input's keydown action posted
the store as JSON with no form encoding, and the form's submit posted its
fields urlencoded with no signals. The ledger row now names this note and
its retiring condition is a bundle whose field actions send the field
alone, which is what the run would show first.
Pre-release, not CI. The run needs Chromium, which the CI runners do
not carry, and what it guards moves only when the bundle pin moves (D20).
The smoke stays the every-pull-request gate for the server's half. The
run costs ten seconds and prints the two bodies as its record, so a moved
pin gets its evidence in the release that ships it; docs/RELEASING.md
lists it beside the other pre-release steps.
Not claimed. Whether a half-typed rename survives a broadcast from
another tab: Datastar morphs #todos by id and this run typed and
submitted before any other tab wrote, so the interaction of a morph with
an input mid-edit was not exercised. Multipart stays D16. And one route,
one field: the form arm is proven for the shape form(req) decodes, not
for enctype="multipart/form-data", which the bundle sends as multipart
and form(req) refuses.