ReviewOS

stacks/stx

feat(forms): no reactive form/mutation primitive — `defineForm` exists but is not exported, and `useMutation` swallows the server's error body

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

Summary

There is no reactive form primitive in stx. useForm does not exist anywhere in packages/stx/src. defineForm exists (packages/stx/src/forms-validation.ts:549) but is not exported from the package entry and is not a runtime global, so no application can reach it; and its values/errors are plain objects rather than signals, so even reached it would not drive a template. useMutation is the only shipped mutation primitive and it throws on !response.ok before reading the body, which makes the server's {message} — the one thing a real form has to display — unreachable.

The consequence in practice is that every form in an app is hand-rolled, identically, once per page.

Impact (real app)

Six near-identical submit handlers in analyticshq: resources/views/login.stx:25-62, register.stx:17-84, sites/new.stx:31-71, pricing.stx:28-52, reset-password.stx:48-96, forgot-password.stx:16-69.

Each one opens with the same two declarations (login.stx:14-15, register.stx:14-15, sites/new.stx:18-19, pricing.stx:23-24, reset-password.stx:16-18, forgot-password.stx:12-14):

const error: StxSignal<string> = state('')
const loading: StxSignal<boolean> = state(false)

and then repeats, byte for byte: e.preventDefault(), the re-entrancy guard if (loading()) return, loading.set(true); error.set(''), headers: {'Content-Type':'application/json'}, JSON.stringify(...), await res.json().catch(() => ({})), if (!res.ok) error.set(data.message || '<fallback>'), catch { … }, finally { loading.set(false) }.

The catch block is literally identical in all six files:

resources/views/login.stx:57            error.set('Something went wrong. Please try again.')
resources/views/register.stx:79         error.set('Something went wrong. Please try again.')
resources/views/pricing.stx:47          err.set('Something went wrong. Please try again.')
resources/views/reset-password.stx:91   error.set('Something went wrong. Please try again.')
resources/views/forgot-password.stx:64  error.set('Something went wrong. Please try again.')
resources/views/sites/new.stx:68        error.set('Something went wrong. Please try again.')

Because the only error channel is one error signal, no form in the app has field-level errors. A multi-field validation failure can render exactly one message.

Evidence in stx

defineForm is defined but unreachable:

  • packages/stx/src/forms-validation.ts:549 — export function defineForm<T>(schema, initialValues?): FormState<T>
  • packages/stx/src/index.ts:54 — export * from './forms'; there is no export * from './forms-validation' anywhere in index.ts, and forms.ts does not re-export it. The only two files that reference the module are process.ts:1395 and lazy-module.ts:7, both internal.
  • grep -c defineForm packages/stx/dist/index.js → 0. dist/index.d.ts → no match.
  • packages/stx/src/runtime-globals.ts:43-58 — STX_RUNTIME_GLOBALS does not list defineForm, so it is not destructured into any client script either.

Even if it were exported, it is not reactive — packages/stx/src/forms-validation.ts:556-568:

const values = {} as { [K in keyof T]: unknown }
const errors = {} as { [K in keyof T]: string[] }
const touched = {} as { [K in keyof T]: boolean }

Plain objects; mutating errors[field] at :576 notifies nothing.

useMutation cannot serve as the fallback — packages/stx/src/signals.ts:1324-1326:

var response = await __stxFetch('useMutation', resolvedUrl, fetchOpts);
if (!response.ok) throw new Error('HTTP ' + response.status + ': ' + response.statusText);
var result = await response.json();

The body is never read on failure, so data.message is gone.

Note for triage: #1843's table lists defineForm and defaultFormClasses under "already-exported stx API". For defineForm on 0.2.155 that is not the case — the export is missing from the entry point.

Prior art

  • Inertia: useForm({ email: '', password: '' }) → form.processing, form.errors.email, form.post('/login'), form.reset().
  • React Hook Form + TanStack useMutation (isPending, error, mutate).
  • Angular reactive forms: FormGroup + control.errors.
  • Laravel Precognition for live server-side validation into the same error bag.

Ask

  1. Export defineForm (and v) from the package entry and add both to STX_RUNTIME_GLOBALS so they resolve inside <script client>.
  2. Back values/errors/touched/dirty with signals so form.errors.email drives a template binding.
  3. Ship useForm(initialValues, { schema? }) returning { values, errors, processing, submit(url, opts), reset() }, wired to the data layer so submit() owns the re-entrancy guard, Content-Type, JSON.stringify, and error mapping.
  4. Make useMutation read the response body on failure and expose it (error().data, error().status), so {message} / {error} / a field-error bag can be mapped into form.errors without an app rewriting the fetch.

Related: #1843, #1677 (built-in form components).

11 days ago

Claiming this one — working on it now.

Sign in to comment on this issue.