ReviewOS

stacks/stx

feat(router): the SPA router intercepts links but not form submissions — every in-app form falls out of the SPA

#1863
Closed glennmichael123 opened this 14 days ago · 1 comment
14 days ago

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 for FormData, requestSubmit, action=, method=.
  • packages/router/src/client.ts:1229,1249,1255,1391 — the complete listener set: click, popstate, mouseover, DOMContentLoaded. The click handler at :1229 is the only path into doSwap.
  • 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 in packages/stx/test/** fixtures and in the string emitted by generateValidationScript (packages/stx/src/forms.ts:1387); the router never registers one.

Prior art

  • Remix — <Form method="post"> submits through the router; useNavigation().state gives 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-get on any element, including forms.
  • SvelteKit — use:enhance on a native <form action>.

Ask

  1. Add a delegated submit listener alongside the existing click listener in packages/router/src/client.ts, gated the same way (same-origin, not target=_blank, no data-no-swap), that serialises the form with FormData, issues the request, and swaps the response through the normal fragment path.
  2. 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.
  3. 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>.
  4. Support method="get" by pushing the serialised query string through pushState + fragment swap, which is exactly what the dashboard date picker needs and would remove its two hidden state-carrying inputs.

Related: #1834, #1777

14 days ago

Fixed in 2819b9d9e3 — packages/router/src/client.ts.

A document-level bubble-phase submit listener now carries a submission through the router instead of letting it fall out of the SPA.

Opt-in, deliberately. A form is claimed by data-stx-form on the form, or by interceptForms: true globally. The default is false — a POST has side effects, so claiming one has to be a decision rather than something that starts happening on upgrade. defaultPrevented is honoured, so a page's own @submit handler always wins; this is a fallback for forms nobody else claimed, never an override.

GET serialises the fields to a query string and hands the URL to navigate(), so it inherits caching, prefetch and the guard-redirect behaviour for free — a GET form is a link whose href the user filled in.

POST goes through fetch with the declared enctype respected (handing FormData straight to fetch always produces multipart, which a server expecting urlencoded reads as empty). A named submit button contributes its value the way a native submit would, so multi-button forms — Save vs Delete — keep the one piece of state that distinguishes them. formaction / formmethod / formenctype on the submitter override the form.

  • POST/redirect/GET: r.redirected sends you to the final URL through the normal navigation path, rather than swapping the POST's body under the form's own URL — otherwise reloading re-submits.
  • The response is never cached: it is the answer to one POST, and serving it later for a GET of the same path would be a lie.
  • Swapped with 'replace', so re-rendered validation errors do not stack one history entry per attempt.
  • stx-submitting class + aria-busy while in flight; stx:form-submit and stx:form-error events for app-level handling.
  • A failure does not natively re-submit — that would send the POST twice. The form is left intact for a retry.

Excluded: data-no-router / data-stx-no-router, a target other than _self, and any action resolving to a different origin (which also catches mailto: — it parses to a null origin). Without a container there is nothing to swap into, so the native submit is left alone.

Proven by packages/router/test/form-submit.test.ts — 16 tests, green.

Scope note: this is client-side enhancement of a form submission. It does not give you a form that works with JavaScript off — that is #1847, which stays open and is the larger piece.

Sign in to comment on this issue.