ReviewOS

stacks/stx

No server-side form handling: every form in a real app is preventDefault + fetch, so nothing works without JavaScript

#1847
Closed glennmichael123 opened this 11 days ago ·
11 days ago

Split out of #1846 because it is not a reachability problem — no client composable fixes it.

Measured in loghq (production, SSR, 33 routes)

Every form in the app:

formactionmethodpreventDefaulthand-rolled fetch
login.stx——✅✅
register.stx——✅✅
forgot-password.stx——✅✅
reset-password.stx——✅✅
projects/new.stx——✅✅

Five of five carry no action and no method. Verified in a browser with script execution disabled: the pages render (SSR works) and the forms are inert. Sign-in, sign-up, password reset and project creation are unavailable if a script fails to load, is blocked, or errors before hydration.

That is not a choice the app made — it is the only shape available. <form @submit="handler"> binds a client handler; there is no server-side counterpart, so the server never sees a form POST from a page it rendered.

What the ecosystem provides

  • Laravel — the form posts to a route; the controller validates; @error and old() repopulate on the redirect back. Works with JS off, enhanced with it on.
  • Remix / SvelteKit — action on the route; <Form> progressively enhances the same handler.
  • Next — Server Actions, same contract.
  • Rails / Django — the original version of this.

In all of them the same handler serves both paths. stx has the server half (a page already runs <script server> per request) and the client half (@submit), with nothing joining them.

Cost in this app, concretely

Each form re-implements the same four states by hand. login.stx alone: 6 references to a loading signal, 10 to error, plus a :disabled binding and a :text label swap. Multiply by five forms. A server form action plus validation would delete most of it and make the pages work without JS.

Ask

A route/page-level form action: a way for a page's server block to receive its own form POST, return field errors, and repopulate — with @submit enhancing it rather than replacing it. If that is a large piece of work, an interim action/method passthrough that at least lets an app point a form at an existing routes/*.ts handler would restore the no-JS path.

Related: #1846 (the composables that would remove the client-side duplication exist but are unreachable), #1843.

11 days ago

Claiming this on the bench side — every form in the app is currently preventDefault + fetch. Pairs with the form a11y work just landed in #1861 (same forms.ts/directive surface). Flagging to avoid double-work; approach to follow.

11 days ago

Two corrections to the premise — the no-JS path is reachable today

I went to scope this and found part of the framing does not hold. Both are checkable in a minute, and they change what is actually left to build.

1. action and method already pass through. The issue offers "an interim action/method passthrough" as a fallback ask. That already works — analyticshq's marketing layout has a plain form that renders intact:

<form action="/api/email/subscribe" method="POST" style="...">

2. @submit does not force preventDefault. It only prevents when the .prevent modifier is present (packages/stx/src/signals.ts:2844). There is no submit-specific special case.

Together those mean <form action="/x" method="POST" @submit="handler"> already progressively enhances: the native POST works with scripting off, and the handler takes over when it loads and calls preventDefault itself.

So this line in the issue is wrong:

That is not a choice the app made — it is the only shape available.

The shape is available. The five loghq forms are @submit-only by choice, and any app that wants the no-JS path can have it today by adding action/method and pointing them at an existing routes/*.ts handler. Worth knowing independently of when the framework feature lands, because it is the difference between "sign-in is unavailable without JS" and "sign-in works, just less smoothly".

What is genuinely missing

The co-located half, and it is real — nothing in forms.ts (1,485 lines) handles a form POST server-side. Today the no-JS path means duplicating the handler: validation in routes/*.ts for the native submit, and again in the page for the enhanced one. What does not exist is a page's own server block receiving its POST, returning field errors, and repopulating — one handler serving both paths, which is the actual Laravel/Remix/SvelteKit contract this issue is asking for.

So the ask stands, minus the interim fallback, and minus the "forms are inert" framing.

Proposed shape

stx already extracts getStaticPaths and getStaticProps from a <script server> block by name (ssg.js), so the convention exists in-tree:

<script server>
export async function action({ request, form }) {
  const result = loginSchema.safeParse(form)
  if (!result.success)
    return { errors: result.error.fieldErrors, values: form }
  // ...
  return redirect('/dashboard')
}
</script>

<form method="POST" @submit="enhance">
  <input name="email" value="">
  @error('email')
</form>

action runs on POST to the page's own URL; whatever it returns is merged into the render context, so errors/values repopulate with no flash storage needed for the common case. @submit stays optional and purely an enhancement.

Picking this up now unless the assignee objects.

11 days ago

The production half is in — 41fd46729f

f5f03e3291 wired action into packages/bun-plugin/src/serve.ts and nowhere else. production-server.ts, stx/src/serve.ts and ssg.ts had zero mentions of it, so a form worked under buddy dev and silently did nothing once deployed — the POST fell through to an ordinary render and answered 200 with the pre-submit markup, which is indistinguishable from the action having run and found nothing wrong.

It now runs inside runHydration, the one place both servers already execute a page's server block: after the block, before any placeholder is resolved, so the errors and values an action returns are the ones the template renders. A redirect is surfaced rather than rendered and answered 303, so a reload does not resubmit. A submission also no longer takes the static fast path — a max-age=60 copy of the pre-submit markup is exactly what "the action did nothing" looks like.

The implementation moved to src/page-action.ts and the dev server calls it, deleting 102 lines of duplicate. This repo keeps getting bitten by two hand-maintained copies of one rule drifting; this one had drifted the moment it was written, by existing in only one copy.

12 tests on the shared path.

One limitation, and it is worth knowing before you write a form

A page written the natural way does not repopulate:

<script server>
export async function action({ form }) { … }
const errors = ''   // so the GET render is clean
const values = ''
</script>
<input name="email" value="">
<p>{{ errors }}</p>

compileTemplate executes server scripts at build time and freezes their values into the HTML, so both expressions become the empty string and no placeholder survives for the action to fill. The action runs, returns its message, merges it — and the template has nowhere to put it. Deleting the two initialisers makes it work, which is not a rule anyone would guess.

That is #1895, not this feature: the same mechanism bakes new Date().toISOString() at build time, so two builds of an unchanged page thirty milliseconds apart produce different output and every visitor sees the build timestamp forever. Pinned here as a test asserting current behaviour with the issue number on it, so fixing #1895 fails loudly here rather than leaving this as folklore.

Leaving this issue open — ssg.ts still has no action path, which is correct for a static build but worth stating explicitly rather than leaving as a gap someone rediscovers.

11 days ago

Closing. All three paths are now accounted for, the last in 05731bb08e.

path
dev serverf5f03e3291
production server41fd46729f
static build05731bb08e — warns, because it structurally cannot

SSG was the open gap, and the right answer there is not to implement it. action receives the page's own POST; a static build produces files, so there is no server left to receive one. The form renders, submits, and gets whatever the host does with a POST to a .html — a 405, or a silent no-op. Nothing about the page looks wrong, so the build now says so:

Warning: /signup exports a page action, which cannot run in a static build.
Its form will submit to a file. Serve this route with the production server,
or point the form's action at a route handler.

A warning, not an error: a page may reasonably be statically built for its GET render and have its action served by something else.

The limitation this shipped with is also gone

41fd46729f landed with a documented flaw — the natural authoring shape did not repopulate:

const errors = ''    // for a clean GET render
const values = ''

Both expressions froze to the empty string at build time, so the action ran, returned its message, and had nowhere to put it. That was #1895, fixed in 925964a2af: an expression reading a server-declared name is now a placeholder resolved per request, not a baked build-time value. The test that asserted the broken behaviour with the issue number on it has been flipped to assert the fix.

And the two corrections to the premise hold

action/method passthrough and @submit not forcing preventDefault were both already true, so progressive enhancement was reachable before any of this. What was missing was the co-located handler, and that is what landed.

Sign in to comment on this issue.