Skip to content

refactor(pages): share form state contracts across adapters - #674

Draft
yangshun wants to merge 7 commits into
voidzero-dev:mainfrom
yangshun:refactor/shared-form-state
Draft

yangshun wants to merge 7 commits into
voidzero-dev:mainfrom
yangshun:refactor/shared-form-state

Conversation

@yangshun

@yangshun yangshun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Moves the form contracts that every adapter promises into core (packages/void/src/pages/form-presentation.ts, next to FormModel/FormMethod). Only contracts that must not drift between adapters are shared. Framework idioms stay in the adapters.

Shared in core (FormState, IslandFormState and FormSubmitOptions are exported from void/pages-client)

Type Members Used by
FormStatus error, pending, hasChanges, wasSuccessful, recentlySuccessful useForm and useIslandForm in all four adapters; FormModel takes pending and error from it
FormState<TData, TEditing> FormStatus + data, errors (field-keyed), reset (with the { initial } overload that core's parseFormResetArgs parses), setError, clearErrors, clearError useForm, all four adapters
IslandFormState<TData, TEditing> FormStatus + data, errors (keyed by any name the action returns), reset (field-typed), clearErrors, clearError, post/put/patch/delete(url) useIslandForm, all four adapters
FormSubmitOptions Omit<VisitOptions, 'method' | 'data'> & { data? }, previously byte-identical in Vue, Svelte and Solid useForm in Vue, Svelte and Solid

Core defines the data and errors types once, including the rule for error keys. Each adapter must pick an editing mode, and there is no default:

  • 'in-place' for Vue and Svelte: apps edit data and errors directly.
  • 'setters' for React and Solid: data and errors are Readonly and change through setData, setError and clearErrors.

Kept in adapters (agreed API decisions; no behavior change)

  • React's post/put/patch/delete(formData: FormData) stay native <form action> callbacks. Vue, Svelte and Solid keep post(opts?: FormSubmitOptions).
  • setData stays in React and Solid only. Vue and Svelte bind to form.data directly.
  • Island submit(url, method) stays in Vue and Svelte only, and island setData stays in React and Solid only.

Each adapter has a local FormReturn<TData> alias. The typed overload and the implementation signature both use it, so they can no longer drift. It replaces each adapter's UntypedForm and its duplicated typed return object. Vue's useIslandForm now uses the same overload pattern. Its returned object is checked against the island contract; before, it was cast to it. Svelte's useIslandForm is now generic like the other three adapters, so form.data and reset() field names are typed from the defaults instead of Record<string, unknown>.

TypeScript compatibility (release note)

Form types now follow each framework's idiom. Runtime behavior is unchanged.

  • Top-level form fields are read-only in every adapter, for both useForm() and useIslandForm(): data, errors, error, pending, hasChanges, wasSuccessful and recentlySuccessful. Writing form.pending = true or replacing form.data = { … } no longer compiles, because a replaced data or errors object is no longer the one the form submits and updates. Form methods are read-only too. This is new for useForm() in @void/react, @void/vue and @void/solid; Svelte and island forms already had read-only fields.
  • React and Solid: the contents of data and errors are read-only (both hooks). form.data.email = … and form.errors.email = … no longer compile; use setData(), setError() and clearErrors(). In React these writes changed the submitted data without re-rendering. Solid stores ignore them. In Solid, a write into form.errors also changed the server errors that every form shares.
  • Vue and Svelte: data and errors stay editable in place (v-model, bind:value, form.errors.title = …). No setters are required.
  • Island errors values are string | undefined in every adapter, matching useForm(). form.errors.title.length now needs a guard; before, it compiled and then crashed when the field had no error.
  • Svelte useIslandForm() infers its data type from defaults, like the other adapters. These now fail to compile: reading a field that is not in defaults, passing an unknown field to reset(), and assigning a value of a different type. A null default needs its full type, for example { photo: null as File | null }, as React, Vue and Solid already require.

Risk

  • Type-only change, apart from Vue's useIslandForm dropping an as cast. The emitted JavaScript is unchanged.
  • TypeScript ignores readonly when checking assignability, so <Form form={form}> and other consumers are unaffected. The typecheck tests now assert this.
  • No user docs change. void/pages-client is adapter-facing, and the documented form API is unchanged.

Verification

  • vp run check:sdk: 12/12 packages pass. Each adapter's form-errors.typecheck.ts and island-form.typecheck.ts pin:
    • read-only top-level fields and methods;
    • that data and errors are editable in Vue and Svelte and read-only in React and Solid;
    • that useForm() is accepted by <Form form> (FormModel);
    • which adapters have setData or submit();
    • each adapter's submit signature;
    • that island errors may be undefined and accept any key;
    • typed island data and reset fields.
  • The four *-pages playgrounds typecheck against the rebuilt adapters.
  • Focused form unit tests: 1295 tests pass.
  • Earlier on this branch: vp run test passed 515 tests with 1 skipped. The 27 platform-unit failures came from a missing generated platform/packages/dashboard/.void/tsconfig.json in a fresh worktree; after generating it, all 27 pass. vp test run passed 759 of 764 files with 1 skipped. Four failed only under full-suite load and all pass when rerun on their own: the astro dev server start timeout, and timeouts in db-template, deploy-direct-r2 and zero-trust-service.
  • vp lint and vp fmt --check are clean on the changed files.
  • After merging main (including refactor(pages): share the Link contract across framework adapters #672 and refactor(pages): share adapter build-plugin code that must stay in sync #673, which share Link and build-plugin code through the same void/pages-client entry), there were no conflicts. vp run check:sdk passes 12/12, the four *-pages playgrounds typecheck, and vp test run form island passes 357 files (7349 tests, 22 skipped).

🤖 Generated with Claude Code

yangshun and others added 2 commits November 2, 2026 20:08
Move the useForm/useIslandForm contracts that every adapter promises
into core form-presentation.ts and export them from void/pages-client:

- FormStatus: error, pending, hasChanges, wasSuccessful,
  recentlySuccessful (both hooks, all adapters)
- FormState<TData>: data plus field-typed errors, reset, setError,
  clearErrors, clearError (useForm, all adapters)
- FormSubmitOptions: router options for option-taking submit methods

Adapters keep their framework idioms local: React's FormData action
methods, setData in React and Solid, and submit(url, method) on Vue
and Svelte island forms. A local FormReturn<TData> keeps each
adapter's typed overload and implementation signature in sync.

React, Vue and Solid useForm state fields are now readonly, matching
Svelte and every island form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover data, errors, error, hasChanges, wasSuccessful and
recentlySuccessful alongside pending in each adapter, so dropping
readonly from any shared state field fails the typecheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yangshun
yangshun marked this pull request as draft November 2, 2026 12:43
yangshun and others added 3 commits November 2, 2026 20:43
Add IslandFormState<TData> next to FormState so useIslandForm's data,
errors, reset, clearErrors, clearError and URL-taking submit methods
cannot drift between adapters. React and Solid add setData; Vue and
Svelte add submit(url, method).

Svelte's useIslandForm is now generic like the other adapters, so
form.data and reset() field names are typed from the defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- FormState and IslandFormState type errors as read-only, so writing
  form.errors.x no longer compiles; setError and clearErrors remain
  the way to change them. In Solid this write mutated the server
  errors shared by every form.
- React and Solid compose the shared state with Readonly<TData>:
  form.data.x = … compiled but skipped setData (React submitted the
  value without re-rendering; Solid stores reject direct writes).
  Vue and Svelte keep writable data for v-model and bind:value.
- Island errors are Partial, so a missing field error reads as
  undefined instead of a string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mutability of data and errors is a framework idiom, not part of the
shared contract. FormState and IslandFormState take the errors type as
a parameter and default both data and errors to editable in place,
which is how Vue and Svelte update reactive state. React and Solid,
whose state changes go through setters, pass Readonly data and errors.

This restores direct edits such as form.errors.title = '…' on Vue and
Svelte island forms, which have no setError. Top-level fields stay
read-only everywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cpojer

cpojer commented Nov 3, 2026

Copy link
Copy Markdown
Member

Is this intentionally still a draft?

@yangshun

yangshun commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Yes still needs a bit more review, there were some framework-specific mutability concerns that AI seemed to be back-and-forth about.

Let me get to it.

yangshun and others added 2 commits November 3, 2026 10:52
The shared form types took the errors type as a free parameter, so React
and Solid restated the error-key rule at each call site and a new adapter
got editable fields by default. Core now defines data and errors once and
each adapter picks an editing mode: 'in-place' (Vue, Svelte) or 'setters'
(React, Solid). Methods are read-only in both form contracts, FormModel
takes pending and error from FormStatus, and Vue's useIslandForm is checked
against the contract instead of cast to it.

Typecheck tests now pin that useForm() fits <Form form>, which adapters
have setData or submit(), each adapter's submit signature, and that island
errors accept any key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants