Summary
The SPA runtime intercepts clicks on anchors and nothing else. A <form> submit is a navigation, and the router does not see it, so any form that posts or gets to a URL tears down the SPA with a full document load — discarding every client signal on the page. The only alternative is a hand-written fetch() in a @submit handler with its own loading and error state, which is what every real app ends up doing.
Impact (real app)
analyticshq has both failure modes.
Full reload inside the SPA. The dashboard's custom date-range picker is a plain method="get" form because the router cannot carry it (resources/views/dashboard.stx:1195-1207), and it has to re-carry the rest of the page's state by hand as hidden inputs:
<form method="get" class="inline-flex gap-1.5 items-center" title="Custom date range">
<input type="hidden" name="site" value="{{ siteId }}">
@if (compare)
<input type="hidden" name="compare" value="1">
@endif
…
<button type="submit">Apply</button>
</form>Clicking Apply reloads the document, so every signal in the 900-line dashboard client block is thrown away and the query-string state has to be reconstructed from scratch.
No feedback at all. resources/layouts/marketing.stx:41-44 is a bare <form action="/api/email/subscribe" method="POST"> — it works, but there is no success or error path, because adding one means giving up the native submit entirely.
Everywhere else the app hand-wrote the fetch: login.stx:76, register.stx:98, reset-password.stx:125, forgot-password.stx:90, sites/new.stx:89, dashboard.stx:1561 and pricing.stx — seven @submit="handleSubmit" handlers, each with its own loading signal, error signal and try/catch.
Evidence in stx
grep -ril "submit" packages/router/src/→ no files. Same forFormData,requestSubmit,action=,method=.packages/router/src/client.ts:1229,1249,1255,1391— the complete listener set:click,popstate,mouseover,DOMContentLoaded. The click handler at:1229is the only path intodoSwap.packages/stx/src/builtins/index.ts:11-21— the builtin component registry:StxLink,StxImage,SafeImage,StxLoadingIndicator,StxToast,StxModal,StxDrawer,Icon,Suspense,TransitionGroup. There is a router-aware<StxLink>and no router-aware<Form>.grep -rn "addEventListener('submit'" packages/— hits only inpackages/stx/test/**fixtures and in the string emitted bygenerateValidationScript(packages/stx/src/forms.ts:1387); the router never registers one.
Prior art
- Remix —
<Form method="post">submits through the router;useNavigation().stategives pending,useActionData()gives the result, and it degrades to a native submit with JS off. - Inertia — the form helper posts through the router and exposes
processing/errors. - Turbo (Rails) — Turbo Drive intercepts form submissions as well as link clicks, by default, with no per-form opt-in.
- HTMX —
hx-post/hx-geton any element, including forms. - SvelteKit —
use:enhanceon a native<form action>.
Ask
- Add a delegated
submitlistener alongside the existingclicklistener inpackages/router/src/client.ts, gated the same way (same-origin, nottarget=_blank, nodata-no-swap), that serialises the form withFormData, issues the request, and swaps the response through the normal fragment path. - Ship a
<Form>builtin that opts in explicitly for apps that don't want blanket interception, exposing pending state the template can bind (form.pending) and the response body for error rendering. - Degrade correctly: with no JS, or with
data-no-swap, the native submit must still work — that is the whole point of leaving it a real<form>. - Support
method="get"by pushing the serialised query string throughpushState+ fragment swap, which is exactly what the dashboard date picker needs and would remove its two hidden state-carrying inputs.