Skip to content
Merged
57 changes: 12 additions & 45 deletions packages/react/src/runtime/use-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,30 +17,21 @@ import {
parseFormResetArgs,
submitAction,
type FormResetOptions,
type FormState,
} from 'void/pages-client';
import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes';
import { RouterContext } from './context.ts';

type SubmitPayload = FormData | { method: string };
type UntypedForm = {
data: Record<string, unknown>;
setData(field: string, value: unknown): void;
errors: Record<string, string>;
error: VoidActionError | null;
pending: boolean;
hasChanges: boolean;
wasSuccessful: boolean;
recentlySuccessful: boolean;
post(formData: FormData): void;
put(formData: FormData): void;
patch(formData: FormData): void;
delete(formData: FormData): void;
reset(options: FormResetOptions, ...fields: ReadonlyArray<string>): void;
reset(...fields: ReadonlyArray<string>): void;
setError(field: string, message: string): void;
clearErrors(...fields: ReadonlyArray<string>): void;
clearError(): void;
};
// Submit methods are native form actions that take FormData.
type FormReturn<TData> = FormState<TData, 'setters'> &
Readonly<{
setData: <K extends keyof TData & string>(field: K, value: TData[K]) => void;
post(formData: FormData): void;
put(formData: FormData): void;
patch(formData: FormData): void;
delete(formData: FormData): void;
}>;

type FormOptions = {
params?: Record<string, string>;
Expand All @@ -54,37 +45,13 @@ export function useForm<U extends ActionUrl & string>(
url: U,
defaults: ResolveActionBody<U>,
options?: ActionFormOptions<U> & Pick<FormOptions, 'onSuccess' | 'onError'>,
): {
data: ResolveActionBody<U>;
setData: <K extends keyof ResolveActionBody<U> & string>(
field: K,
value: ResolveActionBody<U>[K],
) => void;
errors: Partial<Record<keyof ResolveActionBody<U> & string, string>>;
error: VoidActionError | null;
pending: boolean;
hasChanges: boolean;
wasSuccessful: boolean;
recentlySuccessful: boolean;
post(formData: FormData): void;
put(formData: FormData): void;
patch(formData: FormData): void;
delete(formData: FormData): void;
reset(
options: FormResetOptions,
...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
): void;
reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
setError(field: keyof ResolveActionBody<U> & string, message: string): void;
clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
clearError(): void;
};
): FormReturn<ResolveActionBody<U>>;

export function useForm(
url: string,
defaults: Record<string, unknown>,
options?: FormOptions,
): UntypedForm {
): FormReturn<Record<string, unknown>> {
const router = useContext(RouterContext);
const [data, setDataState] = useState<Record<string, unknown>>(() => cloneFormValue(defaults));
const [defaultsState, setDefaultsState] = useState<Record<string, unknown>>(() =>
Expand Down
26 changes: 8 additions & 18 deletions packages/react/src/runtime/use-island-form.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,13 @@
import { useState, useRef, useCallback, useMemo } from 'react';
import { isEqualFormValue, submitIslandAction, type VoidActionError } from 'void/pages-client';
import {
isEqualFormValue,
submitIslandAction,
type IslandFormState,
type VoidActionError,
} from 'void/pages-client';

export type IslandFormReturn<T extends Record<string, unknown>> = Readonly<{
data: T;
setData: <K extends keyof T & string>(field: K, value: T[K]) => void;
errors: Record<string, string>;
error: VoidActionError | null;
pending: boolean;
hasChanges: boolean;
wasSuccessful: boolean;
recentlySuccessful: boolean;
reset: (...fields: ReadonlyArray<keyof T & string>) => void;
clearErrors: (...fields: ReadonlyArray<string>) => void;
clearError: () => void;
post: (url: string) => Promise<void>;
put: (url: string) => Promise<void>;
patch: (url: string) => Promise<void>;
delete: (url: string) => Promise<void>;
}>;
export type IslandFormReturn<T extends Record<string, unknown>> = IslandFormState<T, 'setters'> &
Readonly<{ setData: <K extends keyof T & string>(field: K, value: T[K]) => void }>;

/**
* Form hook for island pages. Uses fetch + page reload instead of the
Expand Down
25 changes: 25 additions & 0 deletions packages/react/test/unit/form-errors.typecheck.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { FormModel } from 'void/pages-client';
import { useForm } from '../../src/runtime/use-form.ts';

declare module 'void/routes' {
Expand All @@ -13,4 +14,28 @@ export function checkFieldErrors(): void {
form.setError('missing', 'Invalid field');
// @ts-expect-error Error messages must be strings.
form.setError('email', 123);
// @ts-expect-error Form state is read-only; update fields with `setData`.
form.data = { email: '' };
// @ts-expect-error Form state is read-only.
form.errors = {};
// @ts-expect-error Form state is read-only.
form.error = null;
// @ts-expect-error Form state is read-only.
form.pending = true;
// @ts-expect-error Form state is read-only.
form.hasChanges = true;
// @ts-expect-error Form state is read-only.
form.wasSuccessful = true;
// @ts-expect-error Form state is read-only.
form.recentlySuccessful = true;
// @ts-expect-error Errors change through setError and clearErrors.
form.errors.email = 'Invalid';
// @ts-expect-error Fields change through setData.
form.data.email = '';
// @ts-expect-error Form methods are read-only.
form.clearError = () => {};
// @ts-expect-error Submit methods are form actions that take FormData.
form.post();
const model: FormModel<[FormData]> = form;
void model;
}
37 changes: 37 additions & 0 deletions packages/react/test/unit/island-form.typecheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useIslandForm } from '../../src/runtime/use-island-form.ts';

export function checkIslandForm(): void {
const form = useIslandForm({ title: '', count: 0 });
const title: string = form.data.title;
const count: number = form.data.count;
void title;
void count;
form.reset('title');
// @ts-expect-error Reset fields must exist in the defaults.
form.reset('missing');
// @ts-expect-error Island form state is read-only.
form.pending = true;
// @ts-expect-error Island form state is read-only.
form.data = { title: '', count: 0 };
// @ts-expect-error Island form state is read-only.
form.errors = {};
// @ts-expect-error Form methods are read-only.
form.clearError = () => {};
// Island errors are keyed by whatever names the action returns.
const nestedError: string | undefined = form.errors['items.0.name'];
void nestedError;
const titleError: string | undefined = form.errors.title;
void titleError;
// @ts-expect-error A field may have no error.
void form.errors.title.length;
// @ts-expect-error Island errors are read-only.
form.errors.title = 'Invalid';
// @ts-expect-error Fields change through setData.
form.data.title = '';
void form.post('/notes');
form.setData('title', 'Hello');
// @ts-expect-error Field values keep their default types.
form.setData('count', '1');
// @ts-expect-error Only Vue and Svelte islands expose submit().
void form.submit('/notes', 'PATCH');
}
61 changes: 12 additions & 49 deletions packages/solid/src/runtime/use-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,70 +10,33 @@ import {
parseFormResetArgs,
submitAction,
type FormResetOptions,
type VisitOptions,
type FormState,
type FormSubmitOptions,
} from 'void/pages-client';
import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes';
import { RouterContext, ErrorsContext } from './context.ts';

type FormSubmitOptions = Omit<VisitOptions, 'method' | 'data'> & {
data?: Record<string, unknown>;
};
type UntypedForm = {
data: Record<string, unknown>;
setData(field: string, value: unknown): void;
errors: Record<string, string>;
error: VoidActionError | null;
pending: boolean;
hasChanges: boolean;
wasSuccessful: boolean;
recentlySuccessful: boolean;
post(opts?: FormSubmitOptions): Promise<void>;
put(opts?: FormSubmitOptions): Promise<void>;
patch(opts?: FormSubmitOptions): Promise<void>;
delete(opts?: FormSubmitOptions): Promise<void>;
reset(options: FormResetOptions, ...fields: ReadonlyArray<string>): void;
reset(...fields: ReadonlyArray<string>): void;
setError(field: string, message: string): void;
clearErrors(...fields: ReadonlyArray<string>): void;
clearError(): void;
};
type FormReturn<TData> = FormState<TData, 'setters'> &
Readonly<{
setData: <K extends keyof TData & string>(field: K, value: TData[K]) => void;
post(opts?: FormSubmitOptions): Promise<void>;
put(opts?: FormSubmitOptions): Promise<void>;
patch(opts?: FormSubmitOptions): Promise<void>;
delete(opts?: FormSubmitOptions): Promise<void>;
}>;

// Typed overload — full inference from PageActionMap (requires codegen)
export function useForm<U extends ActionUrl & string>(
url: U,
defaults: ResolveActionBody<U>,
options?: ActionFormOptions<U>,
): {
data: ResolveActionBody<U>;
setData: <K extends keyof ResolveActionBody<U> & string>(
field: K,
value: ResolveActionBody<U>[K],
) => void;
errors: Partial<Record<keyof ResolveActionBody<U> & string, string>>;
error: VoidActionError | null;
pending: boolean;
hasChanges: boolean;
wasSuccessful: boolean;
recentlySuccessful: boolean;
post(opts?: FormSubmitOptions): Promise<void>;
put(opts?: FormSubmitOptions): Promise<void>;
patch(opts?: FormSubmitOptions): Promise<void>;
delete(opts?: FormSubmitOptions): Promise<void>;
reset(
options: FormResetOptions,
...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
): void;
reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
setError(field: keyof ResolveActionBody<U> & string, message: string): void;
clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
clearError(): void;
};
): FormReturn<ResolveActionBody<U>>;

export function useForm(
url: string,
defaults: Record<string, unknown>,
options?: { params?: Record<string, string>; resetOnSuccess?: boolean },
): UntypedForm {
): FormReturn<Record<string, unknown>> {
const router = useContext(RouterContext);
// ErrorsContext provides the errors signal accessor — a reactive function
// that returns the current global errors object. This allows useForm to
Expand Down
26 changes: 8 additions & 18 deletions packages/solid/src/runtime/use-island-form.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,14 @@
import { createSignal, createMemo } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import { isEqualFormValue, submitIslandAction, type VoidActionError } from 'void/pages-client';
import {
isEqualFormValue,
submitIslandAction,
type IslandFormState,
type VoidActionError,
} from 'void/pages-client';

export type IslandFormReturn<T extends Record<string, unknown>> = Readonly<{
data: T;
setData: <K extends keyof T & string>(field: K, value: T[K]) => void;
errors: Record<string, string>;
error: VoidActionError | null;
pending: boolean;
hasChanges: boolean;
wasSuccessful: boolean;
recentlySuccessful: boolean;
reset: (...fields: ReadonlyArray<keyof T & string>) => void;
clearErrors: (...fields: ReadonlyArray<string>) => void;
clearError: () => void;
post: (url: string) => Promise<void>;
put: (url: string) => Promise<void>;
patch: (url: string) => Promise<void>;
delete: (url: string) => Promise<void>;
}>;
export type IslandFormReturn<T extends Record<string, unknown>> = IslandFormState<T, 'setters'> &
Readonly<{ setData: <K extends keyof T & string>(field: K, value: T[K]) => void }>;

/**
* Form composable for island pages. Uses fetch + page reload instead of the
Expand Down
25 changes: 25 additions & 0 deletions packages/solid/test/unit/form-errors.typecheck.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { FormModel } from 'void/pages-client';
import { useForm } from '../../src/runtime/use-form.ts';

declare module 'void/routes' {
Expand All @@ -13,6 +14,30 @@ export function checkFieldErrors(): void {
form.setError('missing', 'Invalid field');
// @ts-expect-error Error messages must be strings.
form.setError('email', 123);
// @ts-expect-error Form state is read-only; update fields with `setData`.
form.data = { email: '' };
// @ts-expect-error Form state is read-only.
form.errors = {};
// @ts-expect-error Form state is read-only.
form.error = null;
// @ts-expect-error Form state is read-only.
form.pending = true;
// @ts-expect-error Form state is read-only.
form.hasChanges = true;
// @ts-expect-error Form state is read-only.
form.wasSuccessful = true;
// @ts-expect-error Form state is read-only.
form.recentlySuccessful = true;
// @ts-expect-error Errors change through setError and clearErrors.
form.errors.email = 'Invalid';
// @ts-expect-error Fields change through setData.
form.data.email = '';
// @ts-expect-error Form methods are read-only.
form.clearError = () => {};
// @ts-expect-error The submit method sets the HTTP method.
void form.post({ method: 'GET' });
const model: FormModel = form;
void model;
// @ts-expect-error Submission callbacks are specific to the React adapter.
useForm('/__form-errors/solid', { email: '' }, { onSuccess: () => {} });
// @ts-expect-error Submission callbacks are specific to the React adapter.
Expand Down
37 changes: 37 additions & 0 deletions packages/solid/test/unit/island-form.typecheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useIslandForm } from '../../src/runtime/use-island-form.ts';

export function checkIslandForm(): void {
const form = useIslandForm({ title: '', count: 0 });
const title: string = form.data.title;
const count: number = form.data.count;
void title;
void count;
form.reset('title');
// @ts-expect-error Reset fields must exist in the defaults.
form.reset('missing');
// @ts-expect-error Island form state is read-only.
form.pending = true;
// @ts-expect-error Island form state is read-only.
form.data = { title: '', count: 0 };
// @ts-expect-error Island form state is read-only.
form.errors = {};
// @ts-expect-error Form methods are read-only.
form.clearError = () => {};
// Island errors are keyed by whatever names the action returns.
const nestedError: string | undefined = form.errors['items.0.name'];
void nestedError;
const titleError: string | undefined = form.errors.title;
void titleError;
// @ts-expect-error A field may have no error.
void form.errors.title.length;
// @ts-expect-error Island errors are read-only.
form.errors.title = 'Invalid';
// @ts-expect-error Fields change through setData.
form.data.title = '';
void form.post('/notes');
form.setData('title', 'Hello');
// @ts-expect-error Field values keep their default types.
form.setData('count', '1');
// @ts-expect-error Only Vue and Svelte islands expose submit().
void form.submit('/notes', 'PATCH');
}
Loading
Loading