From b93afa703570e256d0116ec0ec7ad4cab2c4f383 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 15 Jun 2026 09:26:25 -0400 Subject: [PATCH 1/5] feat(form): add @clerk/form package --- .changeset/sweet-forms-begin.md | 2 + packages/form/.gitignore | 2 + packages/form/README.md | 152 +++++ packages/form/package.json | 101 +++ packages/form/src/array/index.ts | 116 ++++ packages/form/src/field-group/index.test.ts | 38 ++ packages/form/src/field-group/index.ts | 74 ++ packages/form/src/field/index.ts | 60 ++ packages/form/src/form/index.test.ts | 316 +++++++++ packages/form/src/form/index.ts | 707 ++++++++++++++++++++ packages/form/src/index.ts | 42 ++ packages/form/src/index.type.test.ts | 32 + packages/form/src/react/form-hook.test.tsx | 142 ++++ packages/form/src/react/index.test.tsx | 129 ++++ packages/form/src/react/index.ts | 294 ++++++++ packages/form/src/react/use-store.ts | 68 ++ packages/form/src/standard-schema/index.ts | 31 + packages/form/src/types.ts | 295 ++++++++ packages/form/src/utils/index.ts | 70 ++ packages/form/src/validate/index.ts | 101 +++ packages/form/tsconfig.json | 28 + packages/form/tsconfig.test.json | 10 + packages/form/tsdown.config.mts | 14 + packages/form/vitest.config.mts | 15 + packages/form/vitest.setup.mts | 4 + pnpm-lock.yaml | 98 +++ 26 files changed, 2941 insertions(+) create mode 100644 .changeset/sweet-forms-begin.md create mode 100644 packages/form/.gitignore create mode 100644 packages/form/README.md create mode 100644 packages/form/package.json create mode 100644 packages/form/src/array/index.ts create mode 100644 packages/form/src/field-group/index.test.ts create mode 100644 packages/form/src/field-group/index.ts create mode 100644 packages/form/src/field/index.ts create mode 100644 packages/form/src/form/index.test.ts create mode 100644 packages/form/src/form/index.ts create mode 100644 packages/form/src/index.ts create mode 100644 packages/form/src/index.type.test.ts create mode 100644 packages/form/src/react/form-hook.test.tsx create mode 100644 packages/form/src/react/index.test.tsx create mode 100644 packages/form/src/react/index.ts create mode 100644 packages/form/src/react/use-store.ts create mode 100644 packages/form/src/standard-schema/index.ts create mode 100644 packages/form/src/types.ts create mode 100644 packages/form/src/utils/index.ts create mode 100644 packages/form/src/validate/index.ts create mode 100644 packages/form/tsconfig.json create mode 100644 packages/form/tsconfig.test.json create mode 100644 packages/form/tsdown.config.mts create mode 100644 packages/form/vitest.config.mts create mode 100644 packages/form/vitest.setup.mts diff --git a/.changeset/sweet-forms-begin.md b/.changeset/sweet-forms-begin.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/sweet-forms-begin.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/form/.gitignore b/packages/form/.gitignore new file mode 100644 index 00000000000..5b45b89ce31 --- /dev/null +++ b/packages/form/.gitignore @@ -0,0 +1,2 @@ +/*/ +!/src/ diff --git a/packages/form/README.md b/packages/form/README.md new file mode 100644 index 00000000000..81d3c7a198c --- /dev/null +++ b/packages/form/README.md @@ -0,0 +1,152 @@ +# @clerk/form + +Reactive form primitives for Clerk SDKs — a [TanStack Form](https://tanstack.com/form)-style API whose reactive core is [nanostores](https://github.com/nanostores/nanostores) instead of `@tanstack/store`. + +- **Typed field paths** via nanostores' `AllPaths`/`FromPath` (`'email'`, `'friends[0].name'`). +- **Validation** with plain functions _or_ any [Standard Schema](https://standardschema.dev) (zod 3.24+, valibot, arktype). +- **Async validation** tracked by nanostores `task()` (so tests can `await allTasks()`). +- **Minimal re-renders** in React via selector-based subscriptions. + +> [!NOTE] +> Form data must be an object-literal `type`, not an `interface`. nanostores' path types require `Record`, which only object-literal types satisfy (interfaces lack the implicit index signature). + +## Core + +```ts +import { createForm, createField } from '@clerk/form'; + +type Values = { email: string; friends: { name: string }[] }; + +const form = createForm({ + defaultValues: { email: '', friends: [] }, + validators: { + // form-level validator may target individual fields + onChange: ({ value }) => (value.email ? undefined : { fields: { email: 'Required' } }), + }, + onSubmit: async ({ value }) => { + await api.save(value); + }, +}); + +const email = createField(form, { + name: 'email', + validators: { onChange: ({ value }) => (value.includes('@') ? undefined : 'Invalid email') }, +}); + +email.handleChange('a@b.c'); // setValue + runs change validation +email.handleBlur(); // marks touched + runs blur validation +email.state.value; // 'a@b.c' +email.state.meta.errors; // string[] + +form.state.canSubmit; // derived: !isSubmitting && isValid +await form.handleSubmit(); +``` + +### Validation + +Each validator slot is a function or a Standard Schema. Async slots are debounced and cancel in-flight runs: + +```ts +import { z } from 'zod'; + +createField(form, { + name: 'username', + validators: { + onChange: z.string().min(3), // Standard Schema + onChangeAsync: async ({ value, signal }) => { + const taken = await checkUsername(value, signal); + return taken ? 'Taken' : undefined; + }, + onChangeAsyncDebounceMs: 300, + }, +}); +``` + +Cross-field validation via `listenTo`: + +```ts +createField(form, { + name: 'confirm', + validators: { + onChange: ({ value, fieldApi }) => (value === fieldApi.form.getFieldValue('password') ? undefined : 'Mismatch'), + onChangeListenTo: ['password'], // re-validate when `password` changes + }, +}); +``` + +### Arrays + +Array operations are standalone, tree-shakeable functions (nanostores-style — free functions over the store). Import only what you use; the base form does not bundle them. + +```ts +import { pushFieldValue, insertFieldValue, removeFieldValue, swapFieldValues, moveFieldValues } from '@clerk/form'; + +pushFieldValue(form, 'friends', { name: '' }); +insertFieldValue(form, 'friends', 0, { name: 'Sam' }); +removeFieldValue(form, 'friends', 1); +swapFieldValues(form, 'friends', 0, 1); +moveFieldValues(form, 'friends', 0, 2); +``` + +## React + +```tsx +import { useForm } from '@clerk/form/react'; + +function SignupForm() { + const form = useForm({ defaultValues: { email: '', friends: [] }, onSubmit }); + + return ( +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + > + + {field => ( + <> + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + {field.state.meta.errors[0] && {field.state.meta.errors[0]}} + + )} + + + {/* re-renders only when `canSubmit` changes */} + s.canSubmit}> + {canSubmit => ( + + )} + +
+ ); +} +``` + +`useField`, `useStore` (a selector-aware `useSyncExternalStore` adapter), and `createFormHook` / `createFormHookContexts` (for app-level pre-wired field/form components) are also exported from `@clerk/form/react`. + +## Field groups + +Project a typed subset of the form as a self-contained section: + +```ts +import { createFieldGroup } from '@clerk/form'; + +const address = createFieldGroup({ form, fields: 'address' }); // prefix form +address.setFieldValue('street', 'Main St'); // -> form 'address.street' + +// or an explicit map +const group = createFieldGroup({ form, fields: { street: 'billing.street' } }); +``` diff --git a/packages/form/package.json b/packages/form/package.json new file mode 100644 index 00000000000..85e08dfc348 --- /dev/null +++ b/packages/form/package.json @@ -0,0 +1,101 @@ +{ + "name": "@clerk/form", + "version": "0.1.0", + "private": true, + "description": "Reactive form primitives for Clerk SDKs, built on nanostores.", + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/form" + }, + "license": "MIT", + "author": "Clerk", + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./react": { + "import": { + "types": "./dist/react/index.d.ts", + "default": "./dist/react/index.js" + }, + "require": { + "types": "./dist/react/index.d.cts", + "default": "./dist/react/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "clean": "rimraf ./dist", + "dev": "tsdown --watch src", + "lint": "eslint src", + "size": "size-limit", + "size:why": "size-limit --why", + "test": "vitest run", + "test:ci": "vitest run --maxWorkers=70%", + "test:watch": "vitest" + }, + "dependencies": { + "nanostores": "1.0.1" + }, + "devDependencies": { + "@size-limit/preset-small-lib": "13.0.3", + "@testing-library/react": "^16.0.0", + "@types/react": "catalog:react", + "react": "catalog:react", + "react-dom": "catalog:react", + "rimraf": "6.0.1", + "size-limit": "13.0.3", + "tsdown": "catalog:repo", + "typescript": "catalog:repo" + }, + "peerDependencies": { + "react": "catalog:peer-react" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, + "engines": { + "node": ">=20.9.0" + }, + "size-limit": [ + { + "name": "createForm", + "path": "dist/index.js", + "import": "{ createForm }", + "limit": "5 kB" + }, + { + "name": "createForm + createField", + "path": "dist/index.js", + "import": "{ createForm, createField }", + "limit": "5 kB" + }, + { + "name": "react: useForm", + "path": "dist/react/index.js", + "import": "{ useForm }", + "ignore": [ + "react", + "react-dom" + ], + "limit": "5.5 kB" + } + ] +} diff --git a/packages/form/src/array/index.ts b/packages/form/src/array/index.ts new file mode 100644 index 00000000000..03a9b4d67b3 --- /dev/null +++ b/packages/form/src/array/index.ts @@ -0,0 +1,116 @@ +import type { FieldName, FormApi } from '../types'; + +/** + * Array field operations as standalone, tree-shakeable functions (in the + * nanostores spirit of free functions over stores, e.g. `listenKeys($store)`). + * Import only the ones you use; the base form does not bundle them. + * + * Each is built on `form.setFieldValue`, so it triggers the array field's own + * validation, listeners, and dynamic dependents. Structural operations + * (insert/remove/swap/move/clear) also reset child-field meta so per-row errors + * do not stick to the wrong row after indices shift. + */ + +// Erased view of the form used internally. Threading the deep `AllPaths` path +// union through `setFieldValue`'s generic at definition time hits the TS +// instantiation-depth limit; the typing lives on the public signatures. +interface LooseForm { + getFieldValue(name: string): unknown; + setFieldValue(name: string, value: unknown): void; + _clearChildMeta(name: string): void; +} + +function loose(form: unknown): LooseForm { + return form as LooseForm; +} + +function readArray(form: LooseForm, name: string): unknown[] { + const value = form.getFieldValue(name); + return Array.isArray(value) ? value : []; +} + +/** Append `value` to the array at `name`. */ +export function pushFieldValue( + form: FormApi, + name: FieldName, + value: unknown, +): void { + const f = loose(form); + f.setFieldValue(name, [...readArray(f, name), value]); +} + +/** Insert `value` at `index`. */ +export function insertFieldValue( + form: FormApi, + name: FieldName, + index: number, + value: unknown, +): void { + const f = loose(form); + const next = [...readArray(f, name)]; + next.splice(index, 0, value); + f.setFieldValue(name, next); + f._clearChildMeta(name); +} + +/** Replace the item at `index` (no reindex — indices are unchanged). */ +export function replaceFieldValue( + form: FormApi, + name: FieldName, + index: number, + value: unknown, +): void { + const f = loose(form); + const next = [...readArray(f, name)]; + next[index] = value; + f.setFieldValue(name, next); +} + +/** Remove the item at `index`. */ +export function removeFieldValue( + form: FormApi, + name: FieldName, + index: number, +): void { + const f = loose(form); + const next = [...readArray(f, name)]; + next.splice(index, 1); + f.setFieldValue(name, next); + f._clearChildMeta(name); +} + +/** Swap the items at `a` and `b`. */ +export function swapFieldValues( + form: FormApi, + name: FieldName, + a: number, + b: number, +): void { + const f = loose(form); + const next = [...readArray(f, name)]; + [next[a], next[b]] = [next[b], next[a]]; + f.setFieldValue(name, next); + f._clearChildMeta(name); +} + +/** Move the item at `from` to `to`. */ +export function moveFieldValues( + form: FormApi, + name: FieldName, + from: number, + to: number, +): void { + const f = loose(form); + const next = [...readArray(f, name)]; + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + f.setFieldValue(name, next); + f._clearChildMeta(name); +} + +/** Remove every item from the array at `name`. */ +export function clearFieldValues(form: FormApi, name: FieldName): void { + const f = loose(form); + f.setFieldValue(name, []); + f._clearChildMeta(name); +} diff --git a/packages/form/src/field-group/index.test.ts b/packages/form/src/field-group/index.test.ts new file mode 100644 index 00000000000..6efe34132e2 --- /dev/null +++ b/packages/form/src/field-group/index.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { createForm } from '../form'; +import { createFieldGroup } from './index'; + +describe('createFieldGroup', () => { + it('projects local keys onto the parent form via a prefix', () => { + const form = createForm({ defaultValues: { address: { street: '', city: '' } } }); + const group = createFieldGroup({ form, fields: 'address' }); + expect(group.resolve('street')).toBe('address.street'); + group.setFieldValue('street', 'Main St'); + expect(form.getFieldValue('address.street')).toBe('Main St'); + expect(group.getFieldValue('street')).toBe('Main St'); + }); + + it('projects local keys via an explicit field map', () => { + const form = createForm({ defaultValues: { billingStreet: '', shippingStreet: '' } }); + const group = createFieldGroup({ form, fields: { street: 'billingStreet' } }); + group.setFieldValue('street', '1 Infinite Loop'); + expect(form.getFieldValue('billingStreet')).toBe('1 Infinite Loop'); + }); + + it('throws for a local key missing from the field map', () => { + const form = createForm({ defaultValues: { billingStreet: '' } }); + const group = createFieldGroup({ form, fields: { street: 'billingStreet' } }); + expect(() => group.resolve('city')).toThrow('no mapping for local key "city"'); + expect(() => group.getFieldValue('city')).toThrow('no mapping for local key "city"'); + expect(() => group.setFieldValue('city', 'x')).toThrow('no mapping for local key "city"'); + }); + + it('builds a field with validators on the parent form', () => { + const form = createForm({ defaultValues: { address: { street: '' } } }); + const group = createFieldGroup({ form, fields: 'address' }); + group.getField('street', { onChange: ({ value }: { value: unknown }) => (value ? undefined : 'Required') }); + form.setFieldValue('address.street', ''); + expect(form.state.fieldMeta['address.street'].errors).toEqual(['Required']); + }); +}); diff --git a/packages/form/src/field-group/index.ts b/packages/form/src/field-group/index.ts new file mode 100644 index 00000000000..18ae1960d73 --- /dev/null +++ b/packages/form/src/field-group/index.ts @@ -0,0 +1,74 @@ +import { createField } from '../field'; +import type { FieldApi, FieldName, FormApi } from '../types'; + +/** + * Resolve local group keys to absolute form paths. Either a prefix string + * (`'address'` → `address.street`) or an explicit map (`{ street: 'a.b' }`). + */ +export type FieldGroupFields = Record> | string; + +export interface FieldGroupOptions { + form: FormApi; + fields: FieldGroupFields; +} + +/** + * A typed subset of a form, addressed by local keys. + * + * Intentionally NON-generic and loosely typed: a group accessor is keyed by + * string and a precise, form-typed surface here would nest `FormApi` and + * nanostores' recursive `AllPaths` path types, which overflow TypeScript's + * instantiation-depth limit once the group is built through a wrapper (e.g. + * `useFieldGroup`). Local keys map to form paths; values are `unknown`. For + * precise per-field value typing, use `form.Field` / `createField` with the + * resolved path from {@link FieldGroupApi.resolve}. + */ +export interface FieldGroupApi { + /** Map a local key to its absolute form path. */ + resolve(localName: string): string; + getFieldValue(localName: string): unknown; + setFieldValue(localName: string, value: unknown): void; + /** Register (and return) a field on the parent form for a local key. */ + getField(localName: string, validators?: unknown): FieldApi, never>; + handleSubmit(): Promise; +} + +/** + * Treat a typed subset of the form as a self-contained group. The group owns no + * state — it projects local keys onto the parent form's stores by path, so a + * reusable section can address its own fields without knowing where they live in + * the parent. `options` is typed to your form; the returned group is loose (see + * {@link FieldGroupApi}). + */ +export function createFieldGroup(options: FieldGroupOptions): FieldGroupApi { + const { fields } = options; + // Erased once here so the rest of the body stays free of the deep path types. + const form = options.form as unknown as FormApi>; + + function resolve(localName: string): string { + if (typeof fields === 'string') { + return `${fields}.${localName}`; + } + const path = (fields as Record)[localName]; + if (path === undefined) { + throw new Error(`Field group has no mapping for local key "${localName}".`); + } + return path; + } + + return { + resolve, + getFieldValue(localName) { + return form.getFieldValue(resolve(localName)); + }, + setFieldValue(localName, value) { + form.setFieldValue(resolve(localName), value); + }, + getField(localName, validators) { + return createField(form, { name: resolve(localName), validators } as never); + }, + handleSubmit() { + return form.handleSubmit(); + }, + }; +} diff --git a/packages/form/src/field/index.ts b/packages/form/src/field/index.ts new file mode 100644 index 00000000000..5fafe6aa452 --- /dev/null +++ b/packages/form/src/field/index.ts @@ -0,0 +1,60 @@ +import type { FieldApi, FieldMeta, FieldName, FieldOptions, FieldState, FormApi, ValidationCause } from '../types'; + +const EMPTY_META: FieldMeta = { + isTouched: false, + isBlurred: false, + isDirty: false, + isValidating: false, + errorMap: {}, + errors: [], + isValid: true, + isPristine: true, +}; + +/** + * Build a `FieldApi` handle bound to a form + path. The field owns no state of + * its own — every read and write goes through the form's stores by `name`. + */ +export function buildField>( + form: FormApi, + name: Name, +): FieldApi { + return { + form, + name, + get state(): FieldState { + return { + value: form.getFieldValue(name), + meta: (form.state.fieldMeta[name] as FieldMeta | undefined) ?? EMPTY_META, + }; + }, + mount() { + return form._mountField(name); + }, + handleChange(updater) { + form.setFieldValue(name, updater); + }, + handleBlur() { + form._handleBlur(name); + }, + setValue(updater) { + form.setFieldValue(name, updater); + }, + validate(cause: ValidationCause) { + return form.validateField(name, cause); + }, + }; +} + +/** + * Register a field's options on the form and return its `FieldApi`. The returned + * handle is cached on the form, so repeated calls for the same `name` return the + * same instance. + */ +export function createField>( + form: FormApi, + options: FieldOptions, +): FieldApi { + form._registerField(options.name, options as FieldOptions>); + return form._getField(options.name) as FieldApi; +} diff --git a/packages/form/src/form/index.test.ts b/packages/form/src/form/index.test.ts new file mode 100644 index 00000000000..7f706473b6b --- /dev/null +++ b/packages/form/src/form/index.test.ts @@ -0,0 +1,316 @@ +import { allTasks } from 'nanostores'; +import { describe, expect, it, vi } from 'vitest'; + +import { insertFieldValue, pushFieldValue, removeFieldValue } from '../array'; +import { createField } from '../field'; +import type { StandardSchemaV1 } from '../types'; +import { createForm } from './index'; + +/** Minimal Standard Schema stub for a string field (non-empty + max length). */ +function nonEmpty(message = 'Required'): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'test', + validate: value => (typeof value === 'string' && value.length > 0 ? { value } : { issues: [{ message }] }), + }, + }; +} + +describe('createForm', () => { + it('exposes default values and derived state', () => { + const form = createForm({ defaultValues: { email: '', age: 0 } }); + expect(form.state.values).toEqual({ email: '', age: 0 }); + expect(form.state.isDirty).toBe(false); + expect(form.state.isValid).toBe(true); + expect(form.state.canSubmit).toBe(true); + }); + + it('sets nested and array values by path', () => { + const form = createForm({ defaultValues: { friends: [{ name: '' }] } }); + form.setFieldValue('friends[0].name', 'Sam'); + expect(form.getFieldValue('friends[0].name')).toBe('Sam'); + expect(form.state.isDirty).toBe(true); + }); + + it('tracks dirty against default value', () => { + const form = createForm({ defaultValues: { email: 'a@b.c' } }); + form.setFieldValue('email', 'x@y.z'); + expect(form.state.fieldMeta.email.isDirty).toBe(true); + form.setFieldValue('email', 'a@b.c'); + expect(form.state.fieldMeta.email.isDirty).toBe(false); + }); + + it('runs sync function validators on change', () => { + const form = createForm({ defaultValues: { age: 10 } }); + createField(form, { + name: 'age', + validators: { onChange: ({ value }) => (value < 18 ? 'Too young' : undefined) }, + }); + form.setFieldValue('age', 5); + expect(form.state.fieldMeta.age.errors).toEqual(['Too young']); + expect(form.state.isValid).toBe(false); + form.setFieldValue('age', 20); + expect(form.state.fieldMeta.age.errors).toEqual([]); + expect(form.state.isValid).toBe(true); + }); + + it('runs Standard Schema validators on change', () => { + const form = createForm({ defaultValues: { email: '' } }); + createField(form, { name: 'email', validators: { onChange: nonEmpty('Email required') } }); + form.setFieldValue('email', ''); + expect(form.state.fieldMeta.email.errors).toEqual(['Email required']); + form.setFieldValue('email', 'a@b.c'); + expect(form.state.fieldMeta.email.errors).toEqual([]); + }); + + it('runs async validators tracked by tasks', async () => { + const form = createForm({ defaultValues: { username: '' } }); + createField(form, { + name: 'username', + validators: { + onChangeAsync: async ({ value }) => { + await Promise.resolve(); + return value === 'taken' ? 'Taken' : undefined; + }, + }, + }); + form.setFieldValue('username', 'taken'); + expect(form.state.fieldMeta.username.isValidating).toBe(true); + await allTasks(); + expect(form.state.fieldMeta.username.errors).toEqual(['Taken']); + expect(form.state.fieldMeta.username.isValidating).toBe(false); + }); + + it('distributes form-level validator errors to fields', () => { + const form = createForm({ + defaultValues: { password: '', confirm: '' }, + validators: { + onChange: ({ value }) => (value.password !== value.confirm ? { fields: { confirm: 'Mismatch' } } : undefined), + }, + }); + form.setFieldValue('password', 'a'); + form.setFieldValue('confirm', 'b'); + expect(form.state.fieldMeta.confirm.errors).toEqual(['Mismatch']); + form.setFieldValue('confirm', 'a'); + expect(form.state.fieldMeta.confirm.errors).toEqual([]); + }); + + it('blocks submit when invalid and calls onSubmitInvalid', async () => { + const onSubmit = vi.fn(); + const onSubmitInvalid = vi.fn(); + const form = createForm({ + defaultValues: { email: '' }, + onSubmit, + onSubmitInvalid, + }); + createField(form, { name: 'email', validators: { onSubmit: nonEmpty() } }); + await form.handleSubmit(); + expect(onSubmit).not.toHaveBeenCalled(); + expect(onSubmitInvalid).toHaveBeenCalledOnce(); + expect(form.state.submissionAttempts).toBe(1); + }); + + it('submits when valid', async () => { + const onSubmit = vi.fn(); + const form = createForm({ defaultValues: { email: 'a@b.c' }, onSubmit }); + createField(form, { name: 'email', validators: { onSubmit: nonEmpty() } }); + await form.handleSubmit(); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ value: { email: 'a@b.c' } })); + expect(form.state.isSubmitSuccessful).toBe(true); + }); + + it('marks fields touched on submit', async () => { + const form = createForm({ defaultValues: { email: '' } }); + createField(form, { name: 'email' }); + await form.handleSubmit(); + expect(form.state.fieldMeta.email.isTouched).toBe(true); + }); + + it('resets values and meta', () => { + const form = createForm({ defaultValues: { email: 'a@b.c' } }); + form.setFieldValue('email', 'x'); + expect(form.state.isDirty).toBe(true); + form.reset(); + expect(form.state.values).toEqual({ email: 'a@b.c' }); + expect(form.state.isDirty).toBe(false); + }); + + it('supports array push/insert/remove via free functions', () => { + const form = createForm({ defaultValues: { items: ['a'] as string[] } }); + pushFieldValue(form, 'items', 'b'); + expect(form.getFieldValue('items')).toEqual(['a', 'b']); + insertFieldValue(form, 'items', 1, 'c'); + expect(form.getFieldValue('items')).toEqual(['a', 'c', 'b']); + removeFieldValue(form, 'items', 0); + expect(form.getFieldValue('items')).toEqual(['c', 'b']); + }); + + it('deletes a field value and meta', () => { + const form = createForm({ defaultValues: { a: '1', b: '2' } as Record }); + form.setFieldValue('a', 'x'); + expect(form.state.fieldMeta.a).toBeDefined(); + form.deleteField('a'); + expect(form.getFieldValue('a')).toBeUndefined(); + expect(form.state.fieldMeta.a).toBeUndefined(); + expect(form.getFieldValue('b')).toBe('2'); + }); + + it('revalidates dependents via listenTo', () => { + const form = createForm({ defaultValues: { password: '', confirm: '' } }); + createField(form, { + name: 'confirm', + validators: { + onChange: ({ value, fieldApi }) => (value !== fieldApi.form.getFieldValue('password') ? 'Mismatch' : undefined), + onChangeListenTo: ['password'], + }, + }); + form.setFieldValue('confirm', 'a'); + expect(form.state.fieldMeta.confirm.errors).toEqual(['Mismatch']); + form.setFieldValue('password', 'a'); + expect(form.state.fieldMeta.confirm.errors).toEqual([]); + }); +}); + +describe('createForm async bookkeeping', () => { + it('clears isValidating when a debounced field validator is superseded', async () => { + vi.useFakeTimers(); + try { + const form = createForm({ defaultValues: { username: '' } }); + createField(form, { + name: 'username', + validators: { + onChangeAsync: async ({ value }) => { + await Promise.resolve(); + return value === 'taken' ? 'Taken' : undefined; + }, + onChangeAsyncDebounceMs: 10, + }, + }); + form.setFieldValue('username', 'a'); + form.setFieldValue('username', 'b'); + await vi.advanceTimersByTimeAsync(20); + await allTasks(); + expect(form.state.fieldMeta.username.isValidating).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('settles the promise of a superseded debounced field validator', async () => { + vi.useFakeTimers(); + try { + const form = createForm({ defaultValues: { username: '' } }); + createField(form, { + name: 'username', + validators: { + onChangeAsync: async () => { + await Promise.resolve(); + return undefined; + }, + onChangeAsyncDebounceMs: 10, + }, + }); + const first = form.validateField('username', 'change'); + const second = form.validateField('username', 'change'); + await vi.advanceTimersByTimeAsync(20); + await allTasks(); + await expect(Promise.all([first, second])).resolves.toBeDefined(); + } finally { + vi.useRealTimers(); + } + }); + + it('clears isValidating when a debounced form validator is superseded', async () => { + vi.useFakeTimers(); + try { + const form = createForm({ + defaultValues: { email: '' }, + validators: { + onChangeAsync: async () => { + await Promise.resolve(); + return undefined; + }, + onChangeAsyncDebounceMs: 10, + }, + }); + form.setFieldValue('email', 'a'); + form.setFieldValue('email', 'b'); + await vi.advanceTimersByTimeAsync(20); + await allTasks(); + expect(form.state.isValidating).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('completes handleSubmit when a debounced form validator was superseded', async () => { + const onSubmit = vi.fn(); + const form = createForm({ + defaultValues: { email: '' }, + validators: { + onChangeAsync: async () => { + await Promise.resolve(); + return undefined; + }, + onChangeAsyncDebounceMs: 50, + }, + onSubmit, + }); + form.setFieldValue('email', 'a'); + form.setFieldValue('email', 'b'); + await form.handleSubmit(); + expect(onSubmit).toHaveBeenCalled(); + }); + + it('clears isFormValidating when a sync-slot form validator rejects', async () => { + const form = createForm({ + defaultValues: { email: '' }, + validators: { onSubmit: () => Promise.reject(new Error('boom')) }, + }); + await expect(form.handleSubmit()).rejects.toThrow('boom'); + await allTasks(); + expect(form.state.isValidating).toBe(false); + }); + + it('does not recreate field meta when a pending validator settles after deleteField', async () => { + vi.useFakeTimers(); + try { + const form = createForm({ defaultValues: { friends: [{ name: 'a' }] } }); + createField(form, { + name: 'friends[0].name', + validators: { + onChangeAsync: async () => { + await Promise.resolve(); + return undefined; + }, + onChangeAsyncDebounceMs: 10, + }, + }); + form.setFieldValue('friends[0].name', 'b'); + form.deleteField('friends[0].name'); + await vi.advanceTimersByTimeAsync(20); + await allTasks(); + expect(form.state.fieldMeta['friends[0].name']).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it('clears listener timers on reset', async () => { + vi.useFakeTimers(); + try { + const onChange = vi.fn(); + const form = createForm({ + defaultValues: { email: '' }, + listeners: { onChange, onChangeDebounceMs: 10 }, + }); + form.setFieldValue('email', 'a'); + form.reset(); + await vi.advanceTimersByTimeAsync(20); + expect(onChange).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/form/src/form/index.ts b/packages/form/src/form/index.ts new file mode 100644 index 00000000000..ecc7d09305c --- /dev/null +++ b/packages/form/src/form/index.ts @@ -0,0 +1,707 @@ +import { allTasks, atom, computed, getPath, map, setPath, task } from 'nanostores'; + +import { buildField } from '../field'; +import type { + FieldApi, + FieldMeta, + FieldMetaBase, + FieldName, + FieldOptions, + FieldValidatorContext, + FieldValidators, + FormApi, + FormMetaBase, + FormOptions, + FormState, + ValidationCause, +} from '../types'; +import { clone, deepEqual, flattenErrorMap } from '../utils'; +import { type FormErrors, runFieldValidator, runFormValidator } from '../validate'; + +function freshFieldMeta(): FieldMetaBase { + return { isTouched: false, isBlurred: false, isDirty: false, isValidating: false, errorMap: {} }; +} + +function freshFormMeta(): FormMetaBase { + return { + isSubmitting: false, + isSubmitted: false, + isSubmitSuccessful: false, + isFormValidating: false, + submissionAttempts: 0, + errorMap: {}, + }; +} + +/** Which validator slots run for a given trigger cause. */ +interface Slot { + sync: keyof FieldValidators; + async?: keyof FieldValidators; + debounce?: keyof FieldValidators; +} + +function fieldSlotsFor(cause: ValidationCause): Slot[] { + switch (cause) { + case 'change': + case 'dynamic': + return [{ sync: 'onChange', async: 'onChangeAsync', debounce: 'onChangeAsyncDebounceMs' }]; + case 'blur': + return [{ sync: 'onBlur', async: 'onBlurAsync', debounce: 'onBlurAsyncDebounceMs' }]; + case 'mount': + return [{ sync: 'onMount' }]; + case 'submit': + return [ + { sync: 'onChange', async: 'onChangeAsync' }, + { sync: 'onBlur', async: 'onBlurAsync' }, + { sync: 'onSubmit', async: 'onSubmitAsync' }, + ]; + default: + return []; + } +} + +function formSlotsFor(cause: ValidationCause): Slot[] { + switch (cause) { + case 'change': + return [{ sync: 'onChange', async: 'onChangeAsync', debounce: 'onChangeAsyncDebounceMs' }]; + case 'blur': + return [{ sync: 'onBlur', async: 'onBlurAsync', debounce: 'onBlurAsyncDebounceMs' }]; + case 'mount': + return [{ sync: 'onMount' }]; + case 'submit': + return [ + { sync: 'onChange', async: 'onChangeAsync' }, + { sync: 'onBlur', async: 'onBlurAsync' }, + { sync: 'onSubmit', async: 'onSubmitAsync' }, + ]; + default: + return []; + } +} + +export function createForm(options: FormOptions = {}): FormApi { + const defaults = clone(options.defaultValues ?? ({} as TFormData)); + + const $values = atom(clone(defaults)); + const $fieldMeta = map>({}); + const $formMeta = atom(freshFormMeta()); + + const fieldInfo = new Map>>(); + const fields = new Map>>(); + // Async bookkeeping, keyed by `${name}:${slot}`. + const controllers = new Map(); + const timers = new Map>(); + const listenerTimers = new Map>(); + const pending = new Map(); // in-flight async validators per field + const settlers = new Map void>(); // settles the promise of the schedule owning each key + + const $state = computed([$values, $fieldMeta, $formMeta], (values, fieldMetaMap, formMeta): FormState => { + const fieldMeta: Record = {}; + let isTouched = false; + let isDirty = false; + let isFieldsValid = true; + let anyValidating = formMeta.isFormValidating; + + for (const name in fieldMetaMap) { + const base = fieldMetaMap[name]; + const errors = flattenErrorMap(base.errorMap); + const isValid = errors.length === 0; + fieldMeta[name] = { ...base, errors, isValid, isPristine: !base.isDirty }; + if (base.isTouched) { + isTouched = true; + } + if (base.isDirty) { + isDirty = true; + } + if (!isValid) { + isFieldsValid = false; + } + if (base.isValidating) { + anyValidating = true; + } + } + + const formErrors = flattenErrorMap(formMeta.errorMap); + const isFormValid = formErrors.length === 0; + const isValid = isFormValid && isFieldsValid; + + return { + ...formMeta, + values, + errors: formErrors, + isValidating: anyValidating, + isFieldsValid, + isFormValid, + isValid, + isTouched, + isDirty, + isPristine: !isDirty, + canSubmit: !formMeta.isSubmitting && (options.canSubmitWhenInvalid === true || isValid), + fieldMeta, + }; + }); + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + // Assigned once below; closures above capture it before assignment, so it + // cannot be `const`. + // eslint-disable-next-line prefer-const + let api: FormApi; + + function getField(name: string): FieldApi> { + let field = fields.get(name); + if (!field) { + field = buildField(api, name as FieldName); + fields.set(name, field); + } + return field; + } + + function defaultValueFor(name: string): unknown { + const info = fieldInfo.get(name); + if (info && info.defaultValue !== undefined) { + return info.defaultValue; + } + return getPath(defaults as Record, name as never); + } + + function getFieldValue(name: string): unknown { + return getPath($values.get() as Record, name as never); + } + + function setRawValue(name: string, value: unknown): void { + $values.set(setPath($values.get() as Record, name as never, value as never) as TFormData); + } + + function metaFor(name: string): FieldMetaBase { + return $fieldMeta.get()[name] ?? freshFieldMeta(); + } + + function patchMeta(name: string, patch: Partial): void { + $fieldMeta.setKey(name, { ...metaFor(name), ...patch }); + } + + function setSlotErrors(name: string, slot: string, errors: string[]): void { + const prev = metaFor(name); + const errorMap = { ...prev.errorMap }; + if (errors.length) { + errorMap[slot] = errors; + } else { + delete errorMap[slot]; + } + $fieldMeta.setKey(name, { ...prev, errorMap }); + } + + function setFormSlotErrors(slot: string, errors: string[]): void { + const prev = $formMeta.get(); + const errorMap = { ...prev.errorMap }; + if (errors.length) { + errorMap[slot] = errors; + } else { + delete errorMap[slot]; + } + $formMeta.set({ ...prev, errorMap }); + } + + function freshController(key: string): AbortController { + controllers.get(key)?.abort(); + const controller = new AbortController(); + controllers.set(key, controller); + return controller; + } + + /** + * Tears down every async record for one key. The settler runs last so the + * schedule it belongs to always releases its pending count and resolves its + * promise, even when a timer is cancelled before its task ever runs. + */ + function cancelKey(key: string): void { + clearTimeout(timers.get(key)); + timers.delete(key); + clearTimeout(listenerTimers.get(key)); + listenerTimers.delete(key); + controllers.get(key)?.abort(); + controllers.delete(key); + settlers.get(key)?.(); + } + + function keysFor(prefixes: string[]): string[] { + const all = new Set([...timers.keys(), ...listenerTimers.keys(), ...controllers.keys(), ...settlers.keys()]); + return [...all].filter(key => prefixes.some(prefix => key.startsWith(prefix))); + } + + function disposeField(name: string): void { + for (const key of keysFor([`${name}:`, `L:${name}:`])) { + cancelKey(key); + } + pending.delete(name); + } + + function disposeAll(): void { + for (const key of keysFor([''])) { + cancelKey(key); + } + formPending = 0; + timers.clear(); + listenerTimers.clear(); + controllers.clear(); + settlers.clear(); + pending.clear(); + } + + function incPending(name: string): void { + pending.set(name, (pending.get(name) ?? 0) + 1); + patchMeta(name, { isValidating: true }); + } + + function decPending(name: string): void { + const next = (pending.get(name) ?? 1) - 1; + pending.set(name, next); + if (next <= 0) { + patchMeta(name, { isValidating: false }); + } + } + + // Counted rather than a bare flag: form-level slots overlap, so the last one + // to finish must be the one that clears `isFormValidating`. + let formPending = 0; + + function incFormPending(): void { + formPending += 1; + $formMeta.set({ ...$formMeta.get(), isFormValidating: true }); + } + + function decFormPending(): void { + formPending -= 1; + if (formPending <= 0) { + formPending = 0; + $formMeta.set({ ...$formMeta.get(), isFormValidating: false }); + } + } + + // ------------------------------------------------------------------------- + // Field validation + // ------------------------------------------------------------------------- + + function validateField(name: string, cause: ValidationCause): Promise { + const validators = fieldInfo.get(name)?.validators; + if (!validators) { + return Promise.resolve(flattenErrorMap(metaFor(name).errorMap)); + } + + const promises: Promise[] = []; + const noDebounce = cause === 'submit' || cause === 'mount'; + + for (const slot of fieldSlotsFor(cause)) { + const record = validators as Record; + const syncValidator = record[slot.sync]; + if (syncValidator) { + const key = `${name}:${slot.sync}`; + const controller = freshController(key); + const ctx = { + value: getFieldValue(name), + fieldApi: getField(name), + signal: controller.signal, + } as FieldValidatorContext>; + const result = runFieldValidator(syncValidator as never, ctx); + if (result instanceof Promise) { + incPending(name); + promises.push( + task(async () => { + try { + const errors = await result; + if (!controller.signal.aborted) { + setSlotErrors(name, slot.sync, errors); + } + } finally { + decPending(name); + } + }), + ); + } else { + setSlotErrors(name, slot.sync, result); + } + } + + const asyncSlot = slot.async; + const asyncValidator = asyncSlot ? record[asyncSlot] : undefined; + if (asyncSlot && asyncValidator) { + const debounceMs = noDebounce + ? 0 + : ((slot.debounce ? (record[slot.debounce] as number | undefined) : undefined) ?? + options.asyncDebounceMs ?? + 0); + promises.push(scheduleAsync(name, asyncSlot, asyncValidator, debounceMs)); + } + } + + return Promise.all(promises).then(() => flattenErrorMap(metaFor(name).errorMap)); + } + + function scheduleAsync(name: string, slot: string, validator: unknown, debounceMs: number): Promise { + const key = `${name}:${slot}`; + cancelKey(key); + const controller = freshController(key); + incPending(name); + return new Promise(resolve => { + let settled = false; + const settle = () => { + if (settled) { + return; + } + settled = true; + settlers.delete(key); + decPending(name); + resolve(); + }; + settlers.set(key, settle); + const run = () => + void task(async () => { + try { + const ctx = { + value: getFieldValue(name), + fieldApi: getField(name), + signal: controller.signal, + } as FieldValidatorContext>; + const errors = await runFieldValidator(validator as never, ctx); + if (!controller.signal.aborted) { + setSlotErrors(name, slot, errors); + } + } finally { + settle(); + } + }); + if (debounceMs > 0) { + timers.set(key, setTimeout(run, debounceMs)); + } else { + run(); + } + }); + } + + // ------------------------------------------------------------------------- + // Form-level validation + // ------------------------------------------------------------------------- + + function applyFormErrors(slot: string, errors: FormErrors): void { + setFormSlotErrors(slot, errors.form); + const fieldSlot = `form:${slot}`; + const affected = new Set(Object.keys(errors.fields)); + const current = $fieldMeta.get(); + for (const name in current) { + if (current[name].errorMap[fieldSlot]) { + affected.add(name); + } + } + for (const name of affected) { + setSlotErrors(name, fieldSlot, errors.fields[name] ?? []); + } + } + + function validateForm(cause: ValidationCause): Promise { + const validators = options.validators; + if (!validators) { + return Promise.resolve(); + } + + const promises: Promise[] = []; + const noDebounce = cause === 'submit' || cause === 'mount'; + + for (const slot of formSlotsFor(cause)) { + const record = validators as Record; + const syncValidator = record[slot.sync]; + if (syncValidator) { + const ctx = { value: $values.get(), formApi: api, signal: new AbortController().signal }; + const result = runFormValidator(syncValidator as never, ctx); + if (result instanceof Promise) { + incFormPending(); + promises.push( + (async () => { + try { + applyFormErrors(slot.sync, await result); + } finally { + decFormPending(); + } + })(), + ); + } else { + applyFormErrors(slot.sync, result); + } + } + + const asyncSlot = slot.async; + const asyncValidator = asyncSlot ? record[asyncSlot] : undefined; + if (asyncSlot && asyncValidator) { + const debounceMs = noDebounce + ? 0 + : ((slot.debounce ? (record[slot.debounce] as number | undefined) : undefined) ?? + options.asyncDebounceMs ?? + 0); + promises.push(scheduleFormAsync(asyncSlot, asyncValidator, debounceMs)); + } + } + + return Promise.all(promises).then(() => undefined); + } + + function scheduleFormAsync(slot: string, validator: unknown, debounceMs: number): Promise { + const key = `form:${slot}`; + cancelKey(key); + const controller = freshController(key); + incFormPending(); + return new Promise(resolve => { + let settled = false; + const settle = () => { + if (settled) { + return; + } + settled = true; + settlers.delete(key); + decFormPending(); + resolve(); + }; + settlers.set(key, settle); + const run = () => + void task(async () => { + try { + const ctx = { value: $values.get(), formApi: api, signal: controller.signal }; + const errors = await runFormValidator(validator as never, ctx); + if (!controller.signal.aborted) { + applyFormErrors(slot, errors); + } + } finally { + settle(); + } + }); + if (debounceMs > 0) { + timers.set(key, setTimeout(run, debounceMs)); + } else { + run(); + } + }); + } + + // ------------------------------------------------------------------------- + // Listeners + // ------------------------------------------------------------------------- + + function runFieldListener( + name: string, + key: 'onChange' | 'onBlur', + debounceKey: 'onChangeDebounceMs' | 'onBlurDebounceMs', + ): void { + const listeners = fieldInfo.get(name)?.listeners; + const listener = listeners?.[key]; + if (!listener) { + return; + } + const debounce = listeners?.[debounceKey] ?? 0; + const tkey = `L:${name}:${key}`; + clearTimeout(listenerTimers.get(tkey)); + const fire = () => + (listener as (ctx: { value: unknown; fieldApi: FieldApi> }) => void)({ + value: getFieldValue(name), + fieldApi: getField(name), + }); + if (debounce > 0) { + listenerTimers.set(tkey, setTimeout(fire, debounce)); + } else { + fire(); + } + } + + function runFormListener(key: 'onChange', debounceKey: 'onChangeDebounceMs'): void { + const listener = options.listeners?.[key]; + if (!listener) { + return; + } + const debounce = options.listeners?.[debounceKey] ?? 0; + const tkey = `L:form:${key}`; + clearTimeout(listenerTimers.get(tkey)); + const fire = () => listener({ formApi: api }); + if (debounce > 0) { + listenerTimers.set(tkey, setTimeout(fire, debounce)); + } else { + fire(); + } + } + + function triggerDynamic(sourceName: string): void { + for (const [name, info] of fieldInfo) { + if (name === sourceName) { + continue; + } + const listenTo = info.validators?.onChangeListenTo; + if (listenTo && (listenTo as string[]).includes(sourceName)) { + void validateField(name, 'dynamic'); + } + } + } + + // ------------------------------------------------------------------------- + // Array support + // + // The array operations themselves live in `src/array` as tree-shakeable free + // functions built on `setFieldValue`. Only the child-meta reindex needs store + // access, so it is the single internal hook the form exposes for them. + // ------------------------------------------------------------------------- + + function clearChildMeta(name: string): void { + const prefix = `${name}[`; + const current = $fieldMeta.get(); + let changed = false; + const next: Record = {}; + for (const key in current) { + if (key.startsWith(prefix)) { + changed = true; + } else { + next[key] = current[key]; + } + } + if (changed) { + $fieldMeta.set(next); + } + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + api = { + options, + $values, + $fieldMeta, + $formMeta, + $state, + get state() { + return $state.get(); + }, + + mount() { + void validateForm('mount'); + options.listeners?.onMount?.({ formApi: api }); + return () => { + disposeAll(); + }; + }, + + async handleSubmit() { + const start = $formMeta.get(); + $formMeta.set({ + ...start, + isSubmitting: true, + isSubmitSuccessful: false, + submissionAttempts: start.submissionAttempts + 1, + }); + for (const name of fieldInfo.keys()) { + patchMeta(name, { isTouched: true }); + } + + await Promise.all([...fieldInfo.keys()].map(name => validateField(name, 'submit'))); + await validateForm('submit'); + await allTasks(); + + const state = $state.get(); + const value = $values.get(); + if (!state.isValid && options.canSubmitWhenInvalid !== true) { + $formMeta.set({ ...$formMeta.get(), isSubmitting: false, isSubmitted: true, isSubmitSuccessful: false }); + options.onSubmitInvalid?.({ value, formApi: api }); + return; + } + + try { + await options.onSubmit?.({ value, formApi: api }); + $formMeta.set({ ...$formMeta.get(), isSubmitting: false, isSubmitted: true, isSubmitSuccessful: true }); + options.listeners?.onSubmit?.({ formApi: api }); + } catch (error) { + $formMeta.set({ ...$formMeta.get(), isSubmitting: false, isSubmitted: true, isSubmitSuccessful: false }); + throw error; + } + }, + + reset(values) { + disposeAll(); + $values.set(clone(values ?? defaults)); + $fieldMeta.set({}); + $formMeta.set(freshFormMeta()); + }, + + getFieldValue(name) { + return getFieldValue(name) as never; + }, + setFieldValue(name, updater) { + const prev = getFieldValue(name); + const next = typeof updater === 'function' ? (updater as (p: unknown) => unknown)(prev) : updater; + setRawValue(name, next); + patchMeta(name, { isDirty: !deepEqual(next, defaultValueFor(name)) }); + void validateField(name, 'change'); + void validateForm('change'); + triggerDynamic(name); + runFieldListener(name, 'onChange', 'onChangeDebounceMs'); + runFormListener('onChange', 'onChangeDebounceMs'); + }, + getFieldMeta(name) { + return $state.get().fieldMeta[name]; + }, + setFieldMeta(name, updater) { + const prev = metaFor(name); + const next = typeof updater === 'function' ? updater(prev) : updater; + $fieldMeta.setKey(name, next); + }, + deleteField(name) { + // Dispose before dropping the meta: settling a pending validator calls + // `decPending`, which would otherwise re-create the entry we just removed. + disposeField(name); + // `setPath(..., undefined)` removes the key (object) or splices it (array). + setRawValue(name, undefined); + const current = $fieldMeta.get(); + if (name in current) { + const next = { ...current }; + delete next[name]; + $fieldMeta.set(next); + } + fieldInfo.delete(name); + fields.delete(name); + }, + + validateField(name, cause) { + return validateField(name, cause); + }, + async validateAllFields(cause) { + await Promise.all([...fieldInfo.keys()].map(name => validateField(name, cause))); + }, + + _clearChildMeta(name) { + clearChildMeta(name); + }, + _registerField(name, fieldOptions) { + fieldInfo.set(name, fieldOptions); + if (fieldOptions.defaultValue !== undefined && getFieldValue(name) === undefined) { + setRawValue(name, fieldOptions.defaultValue); + } + if (!fields.has(name)) { + fields.set(name, buildField(api, name as FieldName)); + } + }, + _mountField(name) { + void validateField(name, 'mount'); + fieldInfo.get(name)?.listeners?.onMount?.({ fieldApi: getField(name) as never }); + return () => { + disposeField(name); + }; + }, + _handleBlur(name) { + patchMeta(name, { isBlurred: true, isTouched: true }); + void validateField(name, 'blur'); + void validateForm('blur'); + runFieldListener(name, 'onBlur', 'onBlurDebounceMs'); + }, + _getField(name) { + return getField(name); + }, + }; + + return api; +} diff --git a/packages/form/src/index.ts b/packages/form/src/index.ts new file mode 100644 index 00000000000..de2e1ddd56e --- /dev/null +++ b/packages/form/src/index.ts @@ -0,0 +1,42 @@ +export { createForm } from './form'; +export { createField } from './field'; +export { createFieldGroup } from './field-group'; +export { + clearFieldValues, + insertFieldValue, + moveFieldValues, + pushFieldValue, + removeFieldValue, + replaceFieldValue, + swapFieldValues, +} from './array'; +export { isStandardSchema } from './standard-schema'; +export { normalizeErrors } from './validate'; + +export type { + FieldApi, + FieldListeners, + FieldMeta, + FieldMetaBase, + FieldName, + FieldOptions, + FieldState, + FieldValidatorContext, + FieldValidatorFn, + FieldValidatorOrSchema, + FieldValidators, + FieldValue, + FormApi, + FormMetaBase, + FormOptions, + FormState, + FormValidationResult, + FormValidatorContext, + FormValidatorFn, + FormValidatorOrSchema, + FormValidators, + StandardSchemaV1, + ValidationCause, + ValidationError, +} from './types'; +export type { FieldGroupApi } from './field-group'; diff --git a/packages/form/src/index.type.test.ts b/packages/form/src/index.type.test.ts new file mode 100644 index 00000000000..9355e876033 --- /dev/null +++ b/packages/form/src/index.type.test.ts @@ -0,0 +1,32 @@ +import { describe, expectTypeOf, it } from 'vitest'; + +import { createForm } from './form'; +import type { FieldName, FieldValue } from './types'; + +// NOTE: form data must be a `type` (object-literal), not an `interface`. +// nanostores' `AllPaths` requires `Record`, and only +// object-literal types carry the implicit index signature that satisfies it. +type Data = { + email: string; + age: number; + friends: { name: string }[]; +}; + +describe('field path typing', () => { + it('derives typed paths from form data', () => { + expectTypeOf<'email'>().toMatchTypeOf>(); + expectTypeOf<'friends[0].name'>().toMatchTypeOf>(); + }); + + it('derives the value type at a path', () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + it('types getFieldValue by path', () => { + const form = createForm({ defaultValues: { email: '', age: 0, friends: [] } }); + expectTypeOf(form.getFieldValue('email')).toEqualTypeOf(); + expectTypeOf(form.getFieldValue('age')).toEqualTypeOf(); + }); +}); diff --git a/packages/form/src/react/form-hook.test.tsx b/packages/form/src/react/form-hook.test.tsx new file mode 100644 index 00000000000..2fc5ce72dad --- /dev/null +++ b/packages/form/src/react/form-hook.test.tsx @@ -0,0 +1,142 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { createFormHook, createFormHookContexts, useField, useFieldGroup, useForm } from './index'; + +describe('useField', () => { + it('registers and validates a standalone field', () => { + function Form() { + const form = useForm({ defaultValues: { email: '' } }); + const field = useField({ + form, + name: 'email', + validators: { onChange: ({ value }) => (value ? undefined : 'Required') }, + }); + return ( + <> + field.handleChange(e.target.value)} + /> + {field.state.meta.errors[0] && {field.state.meta.errors[0]}} + + ); + } + render(
); + const input = screen.getByLabelText('email'); + fireEvent.change(input, { target: { value: 'x' } }); + expect(screen.queryByRole('alert')).toBeNull(); + fireEvent.change(input, { target: { value: '' } }); + expect(screen.getByRole('alert').textContent).toBe('Required'); + }); +}); + +describe('useFieldGroup', () => { + it('projects a subset and stays reactive via Subscribe', () => { + function Form() { + const form = useForm({ defaultValues: { address: { street: '' } } }); + const group = useFieldGroup({ form, fields: 'address' }); + // group.get/setFieldValue are non-generic (local key in, value out), which + // also sidesteps the deep-path cost of passing a dynamic name to form.Field. + return ( + s.values.address.street}> + {street => ( + group.setFieldValue('street', e.target.value)} + /> + )} + + ); + } + render(); + const input = screen.getByLabelText('street'); + fireEvent.change(input, { target: { value: 'Main St' } }); + expect(input.value).toBe('Main St'); + }); +}); + +describe('createFormHook', () => { + const { fieldContext, formContext, useFieldContext } = createFormHookContexts(); + const { useAppForm, withForm, withFieldGroup } = createFormHook({ + fieldContext, + formContext, + fieldComponents: {}, + formComponents: {}, + }); + + it('AppField provides the field via context', () => { + function ErrorText() { + const field = useFieldContext<{ name: string }, 'name'>(); + return field.state.meta.errors[0] ? {field.state.meta.errors[0]} : null; + } + function Form() { + const form = useAppForm({ defaultValues: { name: '' } }); + return ( + (value ? undefined : 'Required') }} + > + {field => ( + <> + field.handleChange(e.target.value)} + /> + + + )} + + ); + } + render(); + const input = screen.getByLabelText('name'); + fireEvent.change(input, { target: { value: 'Sam' } }); + expect(screen.queryByRole('alert')).toBeNull(); + fireEvent.change(input, { target: { value: '' } }); + expect(screen.getByRole('alert').textContent).toBe('Required'); + }); + + it('withForm builds a reusable template rendered with a form', () => { + const Section = withForm({ + defaultValues: { name: '' }, + render: ({ form }) => ( + + {field => ( + field.handleChange(e.target.value)} + /> + )} + + ), + }); + function App() { + const form = useAppForm({ defaultValues: { name: 'Ada' } }); + return
; + } + render(); + expect(screen.getByLabelText('name').value).toBe('Ada'); + }); + + it('withFieldGroup builds a reusable section bound to a subset', () => { + const Address = withFieldGroup({ + render: ({ group }) => {String(group.getFieldValue('street'))}, + }); + function App() { + const form = useAppForm({ defaultValues: { address: { street: 'Main St' } } }); + return ( +
+ ); + } + render(); + expect(screen.getByTestId('street').textContent).toBe('Main St'); + }); +}); diff --git a/packages/form/src/react/index.test.tsx b/packages/form/src/react/index.test.tsx new file mode 100644 index 00000000000..952ff787db2 --- /dev/null +++ b/packages/form/src/react/index.test.tsx @@ -0,0 +1,129 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useForm } from './index'; + +describe('useForm (React)', () => { + it('renders a field and updates its value on change', () => { + function Form() { + const form = useForm({ defaultValues: { email: '' } }); + return ( + + {field => ( + field.handleChange(e.target.value)} + /> + )} + + ); + } + render(); + const input = screen.getByLabelText('email'); + fireEvent.change(input, { target: { value: 'a@b.c' } }); + expect(input.value).toBe('a@b.c'); + }); + + it('shows validation errors from a field validator', () => { + function Form() { + const form = useForm({ defaultValues: { email: '' } }); + return ( + (value ? undefined : 'Required') }} + > + {field => ( + <> + field.handleChange(e.target.value)} + /> + {field.state.meta.errors[0] && {field.state.meta.errors[0]}} + + )} + + ); + } + render(); + const input = screen.getByLabelText('email'); + fireEvent.change(input, { target: { value: 'x' } }); + expect(screen.queryByRole('alert')).toBeNull(); + fireEvent.change(input, { target: { value: '' } }); + expect(screen.getByRole('alert').textContent).toBe('Required'); + }); + + it('Subscribe re-renders only for the selected slice', () => { + const renders = vi.fn(); + function Form() { + const form = useForm({ + defaultValues: { email: 'seed' }, + validators: { onChange: ({ value }) => (value.email ? undefined : { fields: { email: 'Required' } }) }, + }); + return ( + <> + + {field => ( + field.handleChange(e.target.value)} + /> + )} + + s.canSubmit}> + {canSubmit => { + renders(canSubmit); + return ( + + ); + }} + + + ); + } + render(); + const button = screen.getByRole('button'); + const input = screen.getByLabelText('email'); + // initially valid (no field registered errors yet) -> canSubmit true + expect(button.disabled).toBe(false); + const initialRenders = renders.mock.calls.length; + fireEvent.change(input, { target: { value: '' } }); // canSubmit true -> false + expect(button.disabled).toBe(true); + fireEvent.change(input, { target: { value: 'a@b.c' } }); // false -> true + expect(button.disabled).toBe(false); + const rendersAfterToggle = renders.mock.calls.length; + // typing again while still valid must NOT re-render the Subscribe (slice unchanged) + fireEvent.change(input, { target: { value: 'a@b.cd' } }); + expect(renders.mock.calls.length).toBe(rendersAfterToggle); + expect(rendersAfterToggle - initialRenders).toBe(2); // exactly the two canSubmit flips + }); + + it('submits valid data', async () => { + const onSubmit = vi.fn(); + function Form() { + const form = useForm({ defaultValues: { name: 'Sam' }, onSubmit }); + return ( + { + e.preventDefault(); + void form.handleSubmit(); + }} + > + + + ); + } + render(
); + fireEvent.click(screen.getByRole('button')); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ value: { name: 'Sam' } })); + }); + }); +}); diff --git a/packages/form/src/react/index.ts b/packages/form/src/react/index.ts new file mode 100644 index 00000000000..60901f8ff98 --- /dev/null +++ b/packages/form/src/react/index.ts @@ -0,0 +1,294 @@ +import { getPath } from 'nanostores'; +import type { Context, ReactNode } from 'react'; +import { createContext, createElement, useContext, useEffect, useMemo, useRef } from 'react'; + +import { createField } from '../field'; +import type { FieldGroupApi, FieldGroupOptions } from '../field-group'; +import { createFieldGroup } from '../field-group'; +import { createForm } from '../form'; +import type { + FieldApi, + FieldListeners, + FieldMeta, + FieldName, + FieldOptions, + FieldValidators, + FieldValue, + FormApi, + FormOptions, + FormState, +} from '../types'; +import { useStore } from './use-store'; + +export { shallowEqual, useStore } from './use-store'; + +// Internals operate on an erased form type. The deeply-recursive `AllPaths` +// path types (used for per-field typing in the public API) blow the TS +// instantiation-depth limit when threaded through generic component wiring, so +// the implementation stays untyped and the typing lives only at the boundary. +type AnyForm = FormApi>; +type AnyFieldApi = FieldApi, never>; + +interface AnyFieldProps { + name: string; + defaultValue?: unknown; + validators?: unknown; + listeners?: unknown; + children: (field: AnyFieldApi) => ReactNode; +} + +/** A field's render slice: its value plus derived meta. */ +interface FieldSlice { + value: unknown; + meta: FieldMeta | undefined; +} + +function fieldSliceEqual(a: FieldSlice, b: FieldSlice): boolean { + if (!Object.is(a.value, b.value)) { + return false; + } + const am = a.meta; + const bm = b.meta; + if (am === bm) { + return true; + } + if (!am || !bm) { + return false; + } + return ( + am.isTouched === bm.isTouched && + am.isBlurred === bm.isBlurred && + am.isDirty === bm.isDirty && + am.isValidating === bm.isValidating && + am.isValid === bm.isValid && + am.errors.length === bm.errors.length && + am.errors.every((e, i) => e === bm.errors[i]) + ); +} + +/** Re-render when the given field's value or meta changes. */ +function useFieldSlice(form: AnyForm, name: string): void { + useStore( + form.$state, + state => ({ value: getPath(state.values, name as never), meta: state.fieldMeta[name] }), + fieldSliceEqual, + ); +} + +// --------------------------------------------------------------------------- +// Field / Subscribe internals (erased) +// --------------------------------------------------------------------------- + +function FieldInner(form: AnyForm, props: AnyFieldProps): ReactNode { + const { name, defaultValue, validators, listeners, children } = props; + const field = useMemo( + () => + createField(form, { name, defaultValue, validators, listeners } as FieldOptions, never>), + // Field options are read at registration; identity is keyed by name. + // eslint-disable-next-line react-hooks/exhaustive-deps + [form, name], + ); + useEffect(() => field.mount(), [field]); + useFieldSlice(form, name); + return children(field); +} + +interface AnySubscribeProps { + selector?: (state: FormState>) => unknown; + children: (selected: unknown) => ReactNode; +} + +function SubscribeInner(form: AnyForm, props: AnySubscribeProps): ReactNode { + const selected = useStore(form.$state, props.selector ?? (state => state)); + return props.children(selected); +} + +// --------------------------------------------------------------------------- +// Public component prop types (per-field typing preserved) +// --------------------------------------------------------------------------- + +export interface FieldProps> { + name: Name; + defaultValue?: FieldValue; + validators?: FieldValidators; + listeners?: FieldListeners; + children: (field: FieldApi) => ReactNode; +} + +export interface SubscribeProps { + selector?: (state: FormState) => S; + children: (selected: S) => ReactNode; +} + +// --------------------------------------------------------------------------- +// useForm / useField +// --------------------------------------------------------------------------- + +export interface ReactFormApi extends FormApi { + Field>(props: FieldProps): ReactNode; + Subscribe>(props: SubscribeProps): ReactNode; +} + +export function useForm(options: FormOptions = {}): ReactFormApi { + const ref = useRef | null>(null); + if (!ref.current) { + const form = createForm(options); + const mut = form as unknown as { Field: unknown; Subscribe: unknown }; + mut.Field = (props: AnyFieldProps) => FieldInner(form as unknown as AnyForm, props); + mut.Subscribe = (props: AnySubscribeProps) => SubscribeInner(form as unknown as AnyForm, props); + ref.current = form as unknown as ReactFormApi; + } + const form = ref.current; + useEffect(() => form.mount(), [form]); + return form; +} + +export interface UseFieldOptions> extends FieldOptions< + TFormData, + Name +> { + form: FormApi; +} + +export function useField>( + options: UseFieldOptions, +): FieldApi { + const { form, name } = options; + const field = useMemo( + () => createField(form, options), + // eslint-disable-next-line react-hooks/exhaustive-deps + [form, name], + ); + useEffect(() => field.mount(), [field]); + useFieldSlice(form as unknown as AnyForm, name as string); + return field; +} + +// --------------------------------------------------------------------------- +// useFieldGroup +// --------------------------------------------------------------------------- + +export interface UseFieldGroupOptions { + // `unknown` (cast inside): `FormApi` is invariant in its data type, so no single + // non-generic form type accepts every typed form, and a generic one would + // re-expand the deep path types here. + form: unknown; + fields: string | Record; +} + +/** + * {@link createFieldGroup} for React — a typed subset of a form. The returned + * group is loosely typed (string keys in, `unknown` out); see {@link FieldGroupApi}. + */ +export function useFieldGroup(options: UseFieldGroupOptions): FieldGroupApi { + // A field group is stateless (pure delegation to the form), so no memoization. + return createFieldGroup(options as unknown as FieldGroupOptions>); +} + +// --------------------------------------------------------------------------- +// createFormHook — app-level pattern with pre-wired components + contexts +// --------------------------------------------------------------------------- + +export interface FormHookContexts { + fieldContext: Context; + formContext: Context; + useFieldContext>(): FieldApi; + useFormContext(): FormApi; +} + +export function createFormHookContexts(): FormHookContexts { + const fieldContext = createContext(null); + const formContext = createContext(null); + return { + fieldContext, + formContext, + useFieldContext() { + const field = useContext(fieldContext); + if (!field) { + throw new Error('useFieldContext must be used within a field component'); + } + return field as never; + }, + useFormContext() { + const form = useContext(formContext); + if (!form) { + throw new Error('useFormContext must be used within a form component'); + } + return form as never; + }, + }; +} + +type ComponentMap = Record ReactNode>; + +export interface CreateFormHookOptions { + fieldContext: Context; + formContext: Context; + fieldComponents: F; + formComponents: M; +} + +export interface AppForm< + TFormData extends object, + F extends ComponentMap, + M extends ComponentMap, +> extends ReactFormApi { + /** Field component that also provides `fieldContext` to custom field components. */ + AppField>(props: FieldProps): ReactNode; + /** The registered custom field components (read inside `AppField`). */ + fieldComponents: F; + /** The registered custom form components. */ + formComponents: M; +} + +export function createFormHook(config: CreateFormHookOptions) { + function useAppForm(options: FormOptions = {}): AppForm { + const base = useForm(options); + const ref = useRef(false); + if (!ref.current) { + ref.current = true; + const mut = base as unknown as { AppField: unknown; fieldComponents: F; formComponents: M }; + mut.fieldComponents = config.fieldComponents; + mut.formComponents = config.formComponents; + mut.AppField = (props: AnyFieldProps) => + FieldInner(base as unknown as AnyForm, { + ...props, + children: field => createElement(config.fieldContext.Provider, { value: field }, props.children(field)), + }); + } + return base as unknown as AppForm; + } + + /** + * Define a reusable form template typed to a form shape, rendered with a + * concrete `form` instance — avoids prop-drilling the form through a section. + * `defaultValues` is type-only; it pins `TFormData` for inference. + */ + function withForm>(opts: { + defaultValues?: TFormData; + props?: Props; + render: (ctx: Props & { form: AppForm }) => ReactNode; + }): (props: Props & { form: AppForm }) => ReactNode { + return props => opts.render({ ...(opts.props ?? ({} as Props)), ...props }); + } + + /** + * Define a reusable section bound to a typed subset of a form. Rendered with a + * `form` + a `fields` projection; builds a `FieldGroupApi` for the section. + */ + function withFieldGroup>(opts: { + props?: Props; + render: (ctx: Props & { group: FieldGroupApi }) => ReactNode; + // `fields` is typed loosely (a prefix or a local→path map) to keep JSX + // inference of the returned component out of the deep `AllPaths` types. + // `form` accepted loosely (see `useFieldGroup`) so the deep path types are + // not re-expanded here. + }): (props: Props & { form: unknown; fields: string | Record }) => ReactNode { + return ({ form, fields, ...rest }) => { + const group = useFieldGroup({ form, fields }); + return opts.render({ ...(opts.props ?? ({} as Props)), ...(rest as Props), group }); + }; + } + + return { useAppForm, withForm, withFieldGroup }; +} diff --git a/packages/form/src/react/use-store.ts b/packages/form/src/react/use-store.ts new file mode 100644 index 00000000000..0cee195632b --- /dev/null +++ b/packages/form/src/react/use-store.ts @@ -0,0 +1,68 @@ +import type { ReadableAtom } from 'nanostores'; +import { useCallback, useRef, useSyncExternalStore } from 'react'; + +/** Shallow equality for arrays and one-level objects; falls back to `Object.is`. */ +export function shallowEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) { + return true; + } + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { + return false; + } + + const aArray = Array.isArray(a); + if (aArray !== Array.isArray(b)) { + return false; + } + if (aArray) { + const aArr = a as unknown[]; + const bArr = b as unknown[]; + if (aArr.length !== bArr.length) { + return false; + } + return aArr.every((v, i) => Object.is(v, bArr[i])); + } + + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) { + return false; + } + return aKeys.every(key => Object.is((a as Record)[key], (b as Record)[key])); +} + +/** + * Subscribe to a nanostores store from React via `useSyncExternalStore`. + * + * A `selector` narrows the store to a slice; `isEqual` skips re-renders when the + * slice is unchanged (defaults to shallow equality, which handles selectors that + * return tuples/objects). This selector layer is what `@nanostores/react`'s + * `useStore` lacks, and it is how `Field`/`Subscribe` keep re-renders minimal. + */ +export function useStore( + $store: ReadableAtom, + selector: (value: T) => S = value => value as unknown as S, + isEqual: (a: S, b: S) => boolean = shallowEqual, +): S { + const selectorRef = useRef(selector); + selectorRef.current = selector; + const isEqualRef = useRef(isEqual); + isEqualRef.current = isEqual; + const cache = useRef<{ value: S } | null>(null); + + // Must be referentially stable so useSyncExternalStore does not resubscribe + // (and re-enter the store) on every commit. + const subscribe = useCallback((onChange: () => void) => $store.listen(onChange), [$store]); + + const getSnapshot = useCallback(() => { + const next = selectorRef.current($store.get()); + const last = cache.current; + if (last && isEqualRef.current(last.value, next)) { + return last.value; + } + cache.current = { value: next }; + return next; + }, [$store]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/packages/form/src/standard-schema/index.ts b/packages/form/src/standard-schema/index.ts new file mode 100644 index 00000000000..06609f627b2 --- /dev/null +++ b/packages/form/src/standard-schema/index.ts @@ -0,0 +1,31 @@ +import type { StandardIssue, StandardResult, StandardSchemaV1 } from '../types'; + +/** Narrow an unknown value to a Standard Schema. */ +export function isStandardSchema(value: unknown): value is StandardSchemaV1 { + return typeof value === 'object' && value !== null && '~standard' in value; +} + +/** Run a Standard Schema. Returns the result, possibly a promise. */ +export function runStandardSchema( + schema: StandardSchemaV1, + value: unknown, +): StandardResult | Promise> { + return schema['~standard'].validate(value); +} + +/** Render a Standard Schema issue path (`['friends', 0, 'name']`) to a field name. */ +export function issuePath(issue: StandardIssue): string { + if (!issue.path || issue.path.length === 0) { + return ''; + } + let out = ''; + for (const segment of issue.path) { + const key = typeof segment === 'object' ? segment.key : segment; + if (typeof key === 'number') { + out += `[${key}]`; + } else { + out += out === '' ? String(key) : `.${String(key)}`; + } + } + return out; +} diff --git a/packages/form/src/types.ts b/packages/form/src/types.ts new file mode 100644 index 00000000000..4bd3cc41d30 --- /dev/null +++ b/packages/form/src/types.ts @@ -0,0 +1,295 @@ +/** + * Core type surface for `@clerk/form`. + * + * The reactive core is nanostores: each form instance owns a few `map` stores + * (values, field meta, form meta) and a `computed` derived `$state`. Fields are + * not separate stores — they are views into the form's stores addressed by a + * typed path (`AllPaths` / `FromPath` from nanostores). + */ + +import type { AllPaths, FromPath, ReadableAtom } from 'nanostores'; + +// --------------------------------------------------------------------------- +// Field addressing +// +// nanostores already provides typed deep paths and value lookup, so we reuse +// them instead of re-deriving `DeepKeys` / `DeepValue`. +// --------------------------------------------------------------------------- + +/** A typed dot/bracket path into the form data (`'email'`, `'friends[0].name'`). */ +export type FieldName = AllPaths & string; + +/** The value type at a given field path. */ +export type FieldValue> = FromPath; + +// --------------------------------------------------------------------------- +// Standard Schema +// +// Minimal vendored copy of the Standard Schema v1 interface +// (https://github.com/standard-schema/standard-schema). Any validator slot +// accepts either a plain function or a Standard Schema (zod 3.24+, valibot, +// arktype, …). Vendored rather than depended on — it is a types-only spec. +// --------------------------------------------------------------------------- + +export interface StandardSchemaV1 { + readonly '~standard': StandardSchemaProps; +} + +interface StandardSchemaProps { + readonly version: 1; + readonly vendor: string; + readonly validate: (value: unknown) => StandardResult | Promise>; + readonly types?: { readonly input: Input; readonly output: Output }; +} + +export type StandardResult = + | { readonly value: Output; readonly issues?: undefined } + | { readonly issues: ReadonlyArray }; + +export interface StandardIssue { + readonly message: string; + readonly path?: ReadonlyArray; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** When a validator runs. `server` is reserved for SSR-seeded errors. */ +export type ValidationCause = 'blur' | 'change' | 'dynamic' | 'mount' | 'server' | 'submit'; + +/** + * The raw return of a validator function. Falsy means "valid". Strings and + * string arrays are errors; objects with a `message` are unwrapped. All are + * normalized to `string[]` (see `normalizeErrors`). + */ +export type ValidationError = string | string[] | { message: string } | false | null | undefined; + +/** Context passed to a field-level validator function. */ +export interface FieldValidatorContext> { + value: FieldValue; + fieldApi: FieldApi; + signal: AbortSignal; +} + +export type FieldValidatorFn> = ( + ctx: FieldValidatorContext, +) => ValidationError | Promise; + +/** A field validator: a plain function or a Standard Schema for the field value. */ +export type FieldValidatorOrSchema> = + | FieldValidatorFn + | StandardSchemaV1>; + +export interface FieldValidators> { + onMount?: FieldValidatorOrSchema; + onChange?: FieldValidatorOrSchema; + onChangeAsync?: FieldValidatorOrSchema; + onChangeAsyncDebounceMs?: number; + /** Re-run this field's `onChange` when any of these fields change. */ + onChangeListenTo?: FieldName[]; + onBlur?: FieldValidatorOrSchema; + onBlurAsync?: FieldValidatorOrSchema; + onBlurAsyncDebounceMs?: number; + onBlurListenTo?: FieldName[]; + onSubmit?: FieldValidatorOrSchema; + onSubmitAsync?: FieldValidatorOrSchema; +} + +/** Context passed to a form-level validator function. */ +export interface FormValidatorContext { + value: TFormData; + formApi: FormApi; + signal: AbortSignal; +} + +/** + * The structured return of a form-level validator: a form-wide error plus + * per-field errors keyed by path. A plain return is treated as a form error. + */ +export interface FormValidationResult { + form?: ValidationError; + fields?: Partial, ValidationError>>; +} + +export type FormValidatorFn = ( + ctx: FormValidatorContext, +) => FormValidationResult | ValidationError | Promise | ValidationError>; + +/** A form validator: a plain function or a Standard Schema for the whole form. */ +export type FormValidatorOrSchema = FormValidatorFn | StandardSchemaV1; + +export interface FormValidators { + onMount?: FormValidatorOrSchema; + onChange?: FormValidatorOrSchema; + onChangeAsync?: FormValidatorOrSchema; + onChangeAsyncDebounceMs?: number; + onBlur?: FormValidatorOrSchema; + onBlurAsync?: FormValidatorOrSchema; + onBlurAsyncDebounceMs?: number; + onSubmit?: FormValidatorOrSchema; + onSubmitAsync?: FormValidatorOrSchema; +} + +// --------------------------------------------------------------------------- +// Listeners (side effects; do not block submission) +// --------------------------------------------------------------------------- + +export interface FieldListeners> { + onChange?: (ctx: { value: FieldValue; fieldApi: FieldApi }) => void; + onChangeDebounceMs?: number; + onBlur?: (ctx: { value: FieldValue; fieldApi: FieldApi }) => void; + onBlurDebounceMs?: number; + onMount?: (ctx: { fieldApi: FieldApi }) => void; +} + +// --------------------------------------------------------------------------- +// Field state +// --------------------------------------------------------------------------- + +/** Stored per-field meta (the source of truth, held in `$fieldMeta`). */ +export interface FieldMetaBase { + isTouched: boolean; + isBlurred: boolean; + isDirty: boolean; + isValidating: boolean; + /** Errors keyed by the validator slot that produced them (`onChange`, `onBlurAsync`, …). */ + errorMap: Record; +} + +/** Per-field meta with derived fields added. */ +export interface FieldMeta extends FieldMetaBase { + /** Flat union of every error in `errorMap`. */ + errors: string[]; + isValid: boolean; + isPristine: boolean; +} + +export interface FieldState> { + value: FieldValue; + meta: FieldMeta; +} + +// --------------------------------------------------------------------------- +// Form state +// --------------------------------------------------------------------------- + +/** Stored form-wide meta (held in `$formMeta`). */ +export interface FormMetaBase { + isSubmitting: boolean; + isSubmitted: boolean; + isSubmitSuccessful: boolean; + /** A form-level async validator is running. */ + isFormValidating: boolean; + submissionAttempts: number; + /** Form-wide errors keyed by validator slot (not attributable to a single field). */ + errorMap: Record; +} + +/** The full derived form state exposed via `form.$state`. */ +export interface FormState extends FormMetaBase { + values: TFormData; + /** Flat union of every form-level error. */ + errors: string[]; + /** Any field or the form is running an async validator. */ + isValidating: boolean; + isFieldsValid: boolean; + isFormValid: boolean; + isValid: boolean; + isTouched: boolean; + isDirty: boolean; + isPristine: boolean; + canSubmit: boolean; + fieldMeta: Record; +} + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export interface FormOptions { + defaultValues?: TFormData; + validators?: FormValidators; + listeners?: { + onChange?: (ctx: { formApi: FormApi }) => void; + onChangeDebounceMs?: number; + onSubmit?: (ctx: { formApi: FormApi }) => void; + onMount?: (ctx: { formApi: FormApi }) => void; + }; + onSubmit?: (ctx: { value: TFormData; formApi: FormApi }) => unknown; + onSubmitInvalid?: (ctx: { value: TFormData; formApi: FormApi }) => void; + /** Allow `canSubmit` to stay `true` even when the form is invalid. */ + canSubmitWhenInvalid?: boolean; + /** Default debounce for all async validators (overridden per validator). */ + asyncDebounceMs?: number; +} + +export interface FieldOptions> { + name: Name; + defaultValue?: FieldValue; + validators?: FieldValidators; + listeners?: FieldListeners; + asyncDebounceMs?: number; +} + +// --------------------------------------------------------------------------- +// API shapes (declared here so option/context types can reference them) +// --------------------------------------------------------------------------- + +export interface FieldApi> { + readonly form: FormApi; + readonly name: Name; + /** Live field state (value + derived meta). Read on access. */ + readonly state: FieldState; + mount(): () => void; + handleChange( + updater: FieldValue | ((prev: FieldValue) => FieldValue), + ): void; + handleBlur(): void; + setValue( + updater: FieldValue | ((prev: FieldValue) => FieldValue), + ): void; + validate(cause: ValidationCause): Promise; +} + +export interface FormApi { + readonly options: FormOptions; + /** Writable store of form values. */ + readonly $values: ReadableAtom; + /** Writable store of stored per-field meta. */ + readonly $fieldMeta: ReadableAtom>; + /** Writable store of stored form meta. */ + readonly $formMeta: ReadableAtom; + /** Derived, read-only full form state. */ + readonly $state: ReadableAtom>; + /** Live snapshot of `$state`. */ + readonly state: FormState; + + mount(): () => void; + handleSubmit(): Promise; + reset(values?: TFormData): void; + + getFieldValue>(name: Name): FieldValue; + setFieldValue>( + name: Name, + updater: FieldValue | ((prev: FieldValue) => FieldValue), + ): void; + getFieldMeta(name: FieldName): FieldMeta | undefined; + setFieldMeta(name: FieldName, updater: FieldMetaBase | ((prev: FieldMetaBase) => FieldMetaBase)): void; + /** Remove a field's value and meta (e.g. when an array row is destroyed). */ + deleteField(name: FieldName): void; + + validateField(name: FieldName, cause: ValidationCause): Promise; + validateAllFields(cause: ValidationCause): Promise; + + /** @internal reindex hook for the array free functions in `@clerk/form`. */ + _clearChildMeta(name: string): void; + /** @internal store field options + cache its `FieldApi`. */ + _registerField(name: string, options: FieldOptions>): void; + /** @internal run mount-time validation/listeners; returns cleanup. */ + _mountField(name: string): () => void; + /** @internal blur orchestration (touched + blur validation + listeners). */ + _handleBlur(name: string): void; + /** @internal get (or lazily build) the cached `FieldApi` for a path. */ + _getField(name: string): FieldApi>; +} diff --git a/packages/form/src/utils/index.ts b/packages/form/src/utils/index.ts new file mode 100644 index 00000000000..46804142fbc --- /dev/null +++ b/packages/form/src/utils/index.ts @@ -0,0 +1,70 @@ +/** Flatten a slot-keyed error map into a deduped flat list. */ +export function flattenErrorMap(map: Record): string[] { + const out: string[] = []; + for (const key in map) { + for (const error of map[key]) { + if (!out.includes(error)) { + out.push(error); + } + } + } + return out; +} + +/** Structural equality for plain form values (objects, arrays, primitives). */ +export function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) { + return true; + } + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { + return false; + } + + const aArray = Array.isArray(a); + if (aArray !== Array.isArray(b)) { + return false; + } + if (aArray) { + const aArr = a as unknown[]; + const bArr = b as unknown[]; + if (aArr.length !== bArr.length) { + return false; + } + for (let i = 0; i < aArr.length; i++) { + if (!deepEqual(aArr[i], bArr[i])) { + return false; + } + } + return true; + } + + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) { + return false; + } + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, key)) { + return false; + } + if (!deepEqual((a as Record)[key], (b as Record)[key])) { + return false; + } + } + return true; +} + +/** Cheap structured clone for default values (plain JSON-like data). */ +export function clone(value: T): T { + if (value === null || typeof value !== 'object') { + return value; + } + if (Array.isArray(value)) { + return value.map(clone) as unknown as T; + } + const out: Record = {}; + for (const key in value) { + out[key] = clone((value as Record)[key]); + } + return out as T; +} diff --git a/packages/form/src/validate/index.ts b/packages/form/src/validate/index.ts new file mode 100644 index 00000000000..1baee8ca3da --- /dev/null +++ b/packages/form/src/validate/index.ts @@ -0,0 +1,101 @@ +import { isStandardSchema, issuePath, runStandardSchema } from '../standard-schema'; +import type { FormValidationResult, StandardSchemaV1, ValidationError } from '../types'; + +/** Normalize any validator return into a flat `string[]`. Falsy means valid. */ +export function normalizeErrors(raw: unknown): string[] { + if (!raw) { + return []; + } + if (Array.isArray(raw)) { + return raw.flatMap(normalizeErrors); + } + if (typeof raw === 'string') { + return [raw]; + } + if (typeof raw === 'object') { + return 'message' in raw ? [String((raw as { message: unknown }).message)] : []; + } + if (typeof raw === 'number' || typeof raw === 'boolean' || typeof raw === 'bigint') { + return [String(raw)]; + } + // functions / symbols are not meaningful error values + return []; +} + +function maybeThen(value: In | Promise, map: (v: In) => Out): Out | Promise { + return value instanceof Promise ? value.then(map) : map(value); +} + +// Loose validator shapes. The form module owns the typed surface and casts to +// these at the call site, keeping the deep `AllPaths` path types out of here. +type LooseValidatorFn = (ctx: { value: unknown; signal: AbortSignal }) => ValidationError | Promise; +type LooseValidator = LooseValidatorFn | StandardSchemaV1; +type LooseCtx = { value: unknown; signal: AbortSignal }; + +// --------------------------------------------------------------------------- +// Field validators +// --------------------------------------------------------------------------- + +/** Run a single field validator (function or schema) → `string[]` (sync or async). */ +export function runFieldValidator(validator: LooseValidator, ctx: LooseCtx): string[] | Promise { + if (isStandardSchema(validator)) { + return maybeThen(runStandardSchema(validator, ctx.value), result => + result.issues ? result.issues.map(i => i.message) : [], + ); + } + return maybeThen(validator(ctx), normalizeErrors); +} + +// --------------------------------------------------------------------------- +// Form validators +// --------------------------------------------------------------------------- + +/** Errors produced by a form-level validator, split into form-wide and per-field. */ +export interface FormErrors { + form: string[]; + fields: Record; +} + +function normalizeFormResult(raw: FormValidationResult | ValidationError): FormErrors { + if (raw && typeof raw === 'object' && !Array.isArray(raw) && ('form' in raw || 'fields' in raw)) { + const result: FormValidationResult = raw; + const fields: Record = {}; + if (result.fields) { + for (const [key, value] of Object.entries(result.fields)) { + const errors = normalizeErrors(value); + if (errors.length) { + fields[key] = errors; + } + } + } + return { form: normalizeErrors(result.form), fields }; + } + return { form: normalizeErrors(raw), fields: {} }; +} + +type LooseFormValidatorFn = (ctx: { + value: unknown; + signal: AbortSignal; +}) => FormValidationResult | ValidationError | Promise | ValidationError>; +type LooseFormValidator = LooseFormValidatorFn | StandardSchemaV1; + +/** Run a single form validator (function or schema) → `FormErrors` (sync or async). */ +export function runFormValidator(validator: LooseFormValidator, ctx: LooseCtx): FormErrors | Promise { + if (isStandardSchema(validator)) { + return maybeThen(runStandardSchema(validator, ctx.value), result => { + const out: FormErrors = { form: [], fields: {} }; + if (result.issues) { + for (const issue of result.issues) { + const path = issuePath(issue); + if (path === '') { + out.form.push(issue.message); + } else { + (out.fields[path] ??= []).push(issue.message); + } + } + } + return out; + }); + } + return maybeThen(validator(ctx), normalizeFormResult); +} diff --git a/packages/form/tsconfig.json b/packages/form/tsconfig.json new file mode 100644 index 00000000000..88221f4f2b8 --- /dev/null +++ b/packages/form/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "rootDir": "src", + "verbatimModuleSyntax": true, + "types": ["node"], + "target": "es2022", + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "preserveWatchOutput": true, + "outDir": "dist", + "noUnusedLocals": true, + "noUnusedParameters": true, + "moduleResolution": "bundler", + "moduleDetection": "force", + "module": "preserve", + "lib": ["ES2023", "DOM", "DOM.Iterable", "WebWorker"], + "jsx": "react-jsx", + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "exclude": ["node_modules", "dist"], + "include": ["src"] +} diff --git a/packages/form/tsconfig.test.json b/packages/form/tsconfig.test.json new file mode 100644 index 00000000000..8d2a128d3fa --- /dev/null +++ b/packages/form/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": true, + "noEmit": true, + "emitDeclarationOnly": false, + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/packages/form/tsdown.config.mts b/packages/form/tsdown.config.mts new file mode 100644 index 00000000000..98b6f6a1eae --- /dev/null +++ b/packages/form/tsdown.config.mts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['./src/index.ts', './src/react/index.ts'], + outDir: './dist', + format: ['cjs', 'esm'], + target: 'es2022', + platform: 'neutral', + sourcemap: true, + clean: true, + dts: true, + // React is a peer; nanostores is a runtime dependency. Neither is bundled. + external: ['react', 'react-dom', 'nanostores'], +}); diff --git a/packages/form/vitest.config.mts b/packages/form/vitest.config.mts new file mode 100644 index 00000000000..ecb60356715 --- /dev/null +++ b/packages/form/vitest.config.mts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + watch: false, + typecheck: { + enabled: true, + tsconfig: './tsconfig.test.json', + include: ['**/*.type.{test,spec}.{ts,tsx}'], + }, + environment: 'jsdom', + include: ['**/*.{test,spec}.{ts,tsx}'], + setupFiles: './vitest.setup.mts', + }, +}); diff --git a/packages/form/vitest.setup.mts b/packages/form/vitest.setup.mts new file mode 100644 index 00000000000..e01cebc1b79 --- /dev/null +++ b/packages/form/vitest.setup.mts @@ -0,0 +1,4 @@ +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(cleanup); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f8e12b32ac..7eab83303c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -760,6 +760,40 @@ importers: specifier: ^5.8.5 version: 5.8.5 + packages/form: + dependencies: + nanostores: + specifier: 1.0.1 + version: 1.0.1 + devDependencies: + '@size-limit/preset-small-lib': + specifier: 13.0.3 + version: 13.0.3(size-limit@13.0.3) + '@testing-library/react': + specifier: ^16.0.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: catalog:react + version: 18.3.28 + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + rimraf: + specifier: 6.0.1 + version: 6.0.1 + size-limit: + specifier: 13.0.3 + version: 13.0.3 + tsdown: + specifier: catalog:repo + version: 0.22.2(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.20.6)(typescript@6.0.3)(unrun@0.2.39(synckit@0.11.11))(vue-tsc@3.2.4(typescript@6.0.3)) + typescript: + specifier: catalog:repo + version: 6.0.3 + packages/headless: dependencies: '@clerk/shared': @@ -5646,6 +5680,23 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@size-limit/esbuild@13.0.3': + resolution: {integrity: sha512-g24wsTxM3N/SaGv1MiiDjTShNrIna1WCNJ7NXMrlsWBHWv1OL0nCyllR1bDDHf+gV41lF7QxX7vknswoOr71DQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + size-limit: 13.0.3 + + '@size-limit/file@13.0.3': + resolution: {integrity: sha512-PWTITIXH5p9aGIf6qq2Fruihn/b9nBQyfkyoAyb6DzFJgS1Ek9MSPJYKxKFLO8jdo0aqSgBPd3sevbS6PyBiJw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + size-limit: 13.0.3 + + '@size-limit/preset-small-lib@13.0.3': + resolution: {integrity: sha512-rqKn1+JkVF5ckZRmcxeQPZ8g0e9Fqddh6bjmDotijXJtN40KJQ+5TG6pTiYq3RaA4nkuEkixHULpDBGcgAARAg==} + peerDependencies: + size-limit: 13.0.3 + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -7741,6 +7792,10 @@ packages: engines: {node: '>=16'} hasBin: true + bytes-iec@3.1.1: + resolution: {integrity: sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==} + engines: {node: '>= 0.8'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -11798,6 +11853,14 @@ packages: engines: {node: ^18 || >=20} hasBin: true + nanoid@6.0.1: + resolution: {integrity: sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==} + engines: {node: ^22 || ^24 || >=26} + hasBin: true + + nanospinner@1.2.2: + resolution: {integrity: sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==} + nanostores@1.0.1: resolution: {integrity: sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw==} engines: {node: ^20.0.0 || >=22.0.0} @@ -13707,6 +13770,11 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + size-limit@13.0.3: + resolution: {integrity: sha512-KVb2aNEU49BwTR21SVjD+2QHP9gBV/nWsTHzNB/heRwXtHyA7lLQiDZDQ1TiNh/B/TZXKAZrHYyTt+cvBUrzYw==} + engines: {node: ^22.18.0 || ^24.0.0 || >=26.0.0} + hasBin: true + skin-tone@2.0.0: resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} engines: {node: '>=8'} @@ -20466,6 +20534,22 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@size-limit/esbuild@13.0.3(size-limit@13.0.3)': + dependencies: + esbuild: 0.28.1 + nanoid: 6.0.1 + size-limit: 13.0.3 + + '@size-limit/file@13.0.3(size-limit@13.0.3)': + dependencies: + size-limit: 13.0.3 + + '@size-limit/preset-small-lib@13.0.3(size-limit@13.0.3)': + dependencies: + '@size-limit/esbuild': 13.0.3(size-limit@13.0.3) + '@size-limit/file': 13.0.3(size-limit@13.0.3) + size-limit: 13.0.3 + '@socket.io/component-emitter@3.1.2': {} '@solana-mobile/mobile-wallet-adapter-protocol-web3js@2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@6.0.3)': @@ -23191,6 +23275,8 @@ snapshots: transitivePeerDependencies: - debug + bytes-iec@3.1.1: {} + bytes@3.1.2: {} c12@3.3.4(magicast@0.5.3): @@ -28261,6 +28347,12 @@ snapshots: nanoid@5.1.6: {} + nanoid@6.0.1: {} + + nanospinner@1.2.2: + dependencies: + picocolors: 1.1.1 + nanostores@1.0.1: {} nanotar@0.3.0: {} @@ -30675,6 +30767,12 @@ snapshots: sisteransi@1.0.5: {} + size-limit@13.0.3: + dependencies: + bytes-iec: 3.1.1 + lilconfig: 3.1.3 + nanospinner: 1.2.2 + skin-tone@2.0.0: dependencies: unicode-emoji-modifier-base: 1.0.0 From da5f89a08bfa28cf844478d92f434836952a105e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 24 Aug 2026 14:57:32 -0400 Subject: [PATCH 2/5] refactor(form): unify async bookkeeping into one work map --- packages/form/src/form/index.ts | 208 +++++++++++++++----------------- 1 file changed, 95 insertions(+), 113 deletions(-) diff --git a/packages/form/src/form/index.ts b/packages/form/src/form/index.ts index ecc7d09305c..1dbbc0ee052 100644 --- a/packages/form/src/form/index.ts +++ b/packages/form/src/form/index.ts @@ -33,6 +33,9 @@ function freshFormMeta(): FormMetaBase { }; } +/** Scope key for form-level async work. `#` cannot appear in a field path, so it never collides with a field named `form`. */ +const FORM_SCOPE = '#form'; + /** Which validator slots run for a given trigger cause. */ interface Slot { sync: keyof FieldValidators; @@ -40,7 +43,7 @@ interface Slot { debounce?: keyof FieldValidators; } -function fieldSlotsFor(cause: ValidationCause): Slot[] { +function slotsFor(cause: ValidationCause): Slot[] { switch (cause) { case 'change': case 'dynamic': @@ -60,25 +63,6 @@ function fieldSlotsFor(cause: ValidationCause): Slot[] { } } -function formSlotsFor(cause: ValidationCause): Slot[] { - switch (cause) { - case 'change': - return [{ sync: 'onChange', async: 'onChangeAsync', debounce: 'onChangeAsyncDebounceMs' }]; - case 'blur': - return [{ sync: 'onBlur', async: 'onBlurAsync', debounce: 'onBlurAsyncDebounceMs' }]; - case 'mount': - return [{ sync: 'onMount' }]; - case 'submit': - return [ - { sync: 'onChange', async: 'onChangeAsync' }, - { sync: 'onBlur', async: 'onBlurAsync' }, - { sync: 'onSubmit', async: 'onSubmitAsync' }, - ]; - default: - return []; - } -} - export function createForm(options: FormOptions = {}): FormApi { const defaults = clone(options.defaultValues ?? ({} as TFormData)); @@ -88,12 +72,19 @@ export function createForm(options: FormOptions>>(); const fields = new Map>>(); - // Async bookkeeping, keyed by `${name}:${slot}`. - const controllers = new Map(); - const timers = new Map>(); - const listenerTimers = new Map>(); - const pending = new Map(); // in-flight async validators per field - const settlers = new Map void>(); // settles the promise of the schedule owning each key + /** + * One unit of in-flight async work, keyed by `${scope}:${slot}` (or + * `L:${scope}:${key}` for listeners). `scope` is a field name or `FORM_SCOPE`, + * and is what `disposeScope` tears down by. + */ + interface AsyncWork { + scope: string; + timer?: ReturnType; + controller?: AbortController; + settle?: () => void; + } + const work = new Map(); + const pending = new Map(); // in-flight async validators per scope const $state = computed([$values, $fieldMeta, $formMeta], (values, fieldMetaMap, formMeta): FormState => { const fieldMeta: Record = {}; @@ -205,79 +196,83 @@ export function createForm(options: FormOptions prefixes.some(prefix => key.startsWith(prefix))); + function cancelWork(key: string): void { + const entry = work.get(key); + if (!entry) { + return; + } + work.delete(key); + clearTimeout(entry.timer); + entry.controller?.abort(); + entry.settle?.(); } - function disposeField(name: string): void { - for (const key of keysFor([`${name}:`, `L:${name}:`])) { - cancelKey(key); + function disposeScope(scope: string): void { + for (const [key, entry] of work) { + if (entry.scope === scope) { + cancelWork(key); + } } - pending.delete(name); + pending.delete(scope); } function disposeAll(): void { - for (const key of keysFor([''])) { - cancelKey(key); + for (const key of [...work.keys()]) { + cancelWork(key); } - formPending = 0; - timers.clear(); - listenerTimers.clear(); - controllers.clear(); - settlers.clear(); + work.clear(); pending.clear(); } - function incPending(name: string): void { - pending.set(name, (pending.get(name) ?? 0) + 1); - patchMeta(name, { isValidating: true }); + /** Run `fire` now, or after `debounce`, replacing any pending run for `key`. */ + function debounceWork(key: string, scope: string, debounce: number, fire: () => void): void { + cancelWork(key); + if (debounce > 0) { + work.set(key, { scope, timer: setTimeout(fire, debounce) }); + } else { + fire(); + } } - function decPending(name: string): void { - const next = (pending.get(name) ?? 1) - 1; - pending.set(name, next); - if (next <= 0) { - patchMeta(name, { isValidating: false }); + /** Publish the validating flag for a scope to whichever store owns it. */ + function setValidating(scope: string, isValidating: boolean): void { + if (scope === FORM_SCOPE) { + $formMeta.set({ ...$formMeta.get(), isFormValidating: isValidating }); + } else { + patchMeta(scope, { isValidating }); } } - // Counted rather than a bare flag: form-level slots overlap, so the last one - // to finish must be the one that clears `isFormValidating`. - let formPending = 0; - - function incFormPending(): void { - formPending += 1; - $formMeta.set({ ...$formMeta.get(), isFormValidating: true }); + // Counted, not a bare flag: slots within a scope overlap, so only the last one + // to finish may clear the flag. + function incPending(scope: string): void { + const next = (pending.get(scope) ?? 0) + 1; + pending.set(scope, next); + if (next === 1) { + setValidating(scope, true); + } } - function decFormPending(): void { - formPending -= 1; - if (formPending <= 0) { - formPending = 0; - $formMeta.set({ ...$formMeta.get(), isFormValidating: false }); + function decPending(scope: string): void { + const next = (pending.get(scope) ?? 1) - 1; + if (next <= 0) { + pending.delete(scope); + setValidating(scope, false); + } else { + pending.set(scope, next); } } @@ -294,12 +289,12 @@ export function createForm(options: FormOptions[] = []; const noDebounce = cause === 'submit' || cause === 'mount'; - for (const slot of fieldSlotsFor(cause)) { + for (const slot of slotsFor(cause)) { const record = validators as Record; const syncValidator = record[slot.sync]; if (syncValidator) { const key = `${name}:${slot.sync}`; - const controller = freshController(key); + const controller = freshController(key, name); const ctx = { value: getFieldValue(name), fieldApi: getField(name), @@ -342,8 +337,8 @@ export function createForm(options: FormOptions { const key = `${name}:${slot}`; - cancelKey(key); - const controller = freshController(key); + cancelWork(key); + const controller = new AbortController(); incPending(name); return new Promise(resolve => { let settled = false; @@ -352,11 +347,11 @@ export function createForm(options: FormOptions void task(async () => { try { @@ -374,7 +369,7 @@ export function createForm(options: FormOptions 0) { - timers.set(key, setTimeout(run, debounceMs)); + entry.timer = setTimeout(run, debounceMs); } else { run(); } @@ -402,27 +397,28 @@ export function createForm(options: FormOptions { const validators = options.validators; - if (!validators) { + // `dynamic` re-runs a dependent field only; the form has nothing extra to do. + if (!validators || cause === 'dynamic') { return Promise.resolve(); } const promises: Promise[] = []; const noDebounce = cause === 'submit' || cause === 'mount'; - for (const slot of formSlotsFor(cause)) { + for (const slot of slotsFor(cause)) { const record = validators as Record; const syncValidator = record[slot.sync]; if (syncValidator) { const ctx = { value: $values.get(), formApi: api, signal: new AbortController().signal }; const result = runFormValidator(syncValidator as never, ctx); if (result instanceof Promise) { - incFormPending(); + incPending(FORM_SCOPE); promises.push( (async () => { try { applyFormErrors(slot.sync, await result); } finally { - decFormPending(); + decPending(FORM_SCOPE); } })(), ); @@ -447,10 +443,10 @@ export function createForm(options: FormOptions { - const key = `form:${slot}`; - cancelKey(key); - const controller = freshController(key); - incFormPending(); + const key = `${FORM_SCOPE}:${slot}`; + cancelWork(key); + const controller = new AbortController(); + incPending(FORM_SCOPE); return new Promise(resolve => { let settled = false; const settle = () => { @@ -458,11 +454,11 @@ export function createForm(options: FormOptions void task(async () => { try { @@ -476,7 +472,7 @@ export function createForm(options: FormOptions 0) { - timers.set(key, setTimeout(run, debounceMs)); + entry.timer = setTimeout(run, debounceMs); } else { run(); } @@ -497,19 +493,12 @@ export function createForm(options: FormOptions (listener as (ctx: { value: unknown; fieldApi: FieldApi> }) => void)({ value: getFieldValue(name), fieldApi: getField(name), }); - if (debounce > 0) { - listenerTimers.set(tkey, setTimeout(fire, debounce)); - } else { - fire(); - } + debounceWork(`L:${name}:${key}`, name, listeners?.[debounceKey] ?? 0, fire); } function runFormListener(key: 'onChange', debounceKey: 'onChangeDebounceMs'): void { @@ -517,15 +506,8 @@ export function createForm(options: FormOptions listener({ formApi: api }); - if (debounce > 0) { - listenerTimers.set(tkey, setTimeout(fire, debounce)); - } else { - fire(); - } + debounceWork(`L:${FORM_SCOPE}:${key}`, FORM_SCOPE, options.listeners?.[debounceKey] ?? 0, fire); } function triggerDynamic(sourceName: string): void { @@ -653,7 +635,7 @@ export function createForm(options: FormOptions(options: FormOptions { - disposeField(name); + disposeScope(name); }; }, _handleBlur(name) { From 2bc42e981035c200bb806a3f9d3cdd84061a1946 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 24 Aug 2026 15:01:04 -0400 Subject: [PATCH 3/5] docs(form): trim comments that restate the code --- packages/form/src/array/index.ts | 5 ++--- packages/form/src/form/index.ts | 9 ++------- packages/form/src/react/index.ts | 1 + 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/form/src/array/index.ts b/packages/form/src/array/index.ts index 03a9b4d67b3..12a443cd938 100644 --- a/packages/form/src/array/index.ts +++ b/packages/form/src/array/index.ts @@ -1,9 +1,8 @@ import type { FieldName, FormApi } from '../types'; /** - * Array field operations as standalone, tree-shakeable functions (in the - * nanostores spirit of free functions over stores, e.g. `listenKeys($store)`). - * Import only the ones you use; the base form does not bundle them. + * Array field operations as standalone, tree-shakeable functions. Import only + * the ones you use; the base form does not bundle them. * * Each is built on `form.setFieldValue`, so it triggers the array field's own * validation, listeners, and dynamic dependents. Structural operations diff --git a/packages/form/src/form/index.ts b/packages/form/src/form/index.ts index 1dbbc0ee052..aab3860ec43 100644 --- a/packages/form/src/form/index.ts +++ b/packages/form/src/form/index.ts @@ -72,11 +72,8 @@ export function createForm(options: FormOptions>>(); const fields = new Map>>(); - /** - * One unit of in-flight async work, keyed by `${scope}:${slot}` (or - * `L:${scope}:${key}` for listeners). `scope` is a field name or `FORM_SCOPE`, - * and is what `disposeScope` tears down by. - */ + // Keyed by `${scope}:${slot}`, or `L:${scope}:${key}` for listeners. `scope` + // is a field name or `FORM_SCOPE`, and is what `disposeScope` tears down by. interface AsyncWork { scope: string; timer?: ReturnType; @@ -237,7 +234,6 @@ export function createForm(options: FormOptions void): void { cancelWork(key); if (debounce > 0) { @@ -247,7 +243,6 @@ export function createForm(options: FormOptions createField(form, options), + // Field options are read at registration; identity is keyed by name. // eslint-disable-next-line react-hooks/exhaustive-deps [form, name], ); From 2db534128e4931cb1d96e683e64dbcd4b24fe80c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 24 Aug 2026 15:08:54 -0400 Subject: [PATCH 4/5] fix(form): record validator throws instead of leaking unhandled rejections --- packages/form/src/form/index.test.ts | 43 ++++++++++++++++++- packages/form/src/form/index.ts | 63 +++++++++++++++++++++++----- 2 files changed, 95 insertions(+), 11 deletions(-) diff --git a/packages/form/src/form/index.test.ts b/packages/form/src/form/index.test.ts index 7f706473b6b..a2ac9df6b51 100644 --- a/packages/form/src/form/index.test.ts +++ b/packages/form/src/form/index.test.ts @@ -268,11 +268,52 @@ describe('createForm async bookkeeping', () => { defaultValues: { email: '' }, validators: { onSubmit: () => Promise.reject(new Error('boom')) }, }); - await expect(form.handleSubmit()).rejects.toThrow('boom'); + await form.handleSubmit(); await allTasks(); expect(form.state.isValidating).toBe(false); }); + it('records a rejected async field validator as a field error', async () => { + const form = createForm({ defaultValues: { email: '' } }); + createField(form, { + name: 'email', + validators: { onChangeAsync: () => Promise.reject(new Error('network down')) }, + }); + form.setFieldValue('email', 'a'); + await allTasks(); + expect(form.state.fieldMeta.email.errors).toContain('network down'); + expect(form.state.isValidating).toBe(false); + }); + + it('records a field validator that throws synchronously as a field error', async () => { + const form = createForm({ defaultValues: { email: '' } }); + createField(form, { + name: 'email', + validators: { + onChange: () => { + throw new Error('bad validator'); + }, + }, + }); + form.setFieldValue('email', 'a'); + await allTasks(); + expect(form.state.fieldMeta.email.errors).toContain('bad validator'); + }); + + it('records a rejected form validator as a form error and blocks submit', async () => { + const onSubmit = vi.fn(); + const form = createForm({ + defaultValues: { email: 'a@b.c' }, + validators: { onSubmit: () => Promise.reject(new Error('boom')) }, + onSubmit, + }); + await form.handleSubmit(); + await allTasks(); + expect(form.state.errors).toContain('boom'); + expect(onSubmit).not.toHaveBeenCalled(); + expect(form.state.isSubmitting).toBe(false); + }); + it('does not recreate field meta when a pending validator settles after deleteField', async () => { vi.useFakeTimers(); try { diff --git a/packages/form/src/form/index.ts b/packages/form/src/form/index.ts index aab3860ec43..caf91ac224d 100644 --- a/packages/form/src/form/index.ts +++ b/packages/form/src/form/index.ts @@ -33,6 +33,14 @@ function freshFormMeta(): FormMetaBase { }; } +/** + * A validator that throws is not evidence the value is valid, so the throw is + * recorded as an error on the slot that produced it rather than propagated. + */ +function thrownError(error: unknown): string[] { + return [error instanceof Error ? error.message : String(error)]; +} + /** Scope key for form-level async work. `#` cannot appear in a field path, so it never collides with a field named `form`. */ const FORM_SCOPE = '#form'; @@ -295,16 +303,27 @@ export function createForm(options: FormOptions>; - const result = runFieldValidator(syncValidator as never, ctx); + let result: string[] | Promise; + try { + result = runFieldValidator(syncValidator as never, ctx); + } catch (error) { + setSlotErrors(name, slot.sync, thrownError(error)); + continue; + } if (result instanceof Promise) { + const pendingResult = result; incPending(name); promises.push( task(async () => { try { - const errors = await result; + const errors = await pendingResult; if (!controller.signal.aborted) { setSlotErrors(name, slot.sync, errors); } + } catch (error) { + if (!controller.signal.aborted) { + setSlotErrors(name, slot.sync, thrownError(error)); + } } finally { decPending(name); } @@ -359,6 +378,10 @@ export function createForm(options: FormOptions(options: FormOptions; + try { + result = runFormValidator(syncValidator as never, ctx); + } catch (error) { + applyFormErrors(slot.sync, { form: thrownError(error), fields: {} }); + continue; + } if (result instanceof Promise) { + const pendingResult = result; incPending(FORM_SCOPE); promises.push( (async () => { try { - applyFormErrors(slot.sync, await result); + applyFormErrors(slot.sync, await pendingResult); + } catch (error) { + applyFormErrors(slot.sync, { form: thrownError(error), fields: {} }); } finally { decPending(FORM_SCOPE); } @@ -462,6 +494,10 @@ export function createForm(options: FormOptions(options: FormOptions validateField(name, 'submit'))); + await validateForm('submit'); + await allTasks(); + } catch (error) { + // Validator throws are recorded as errors, so reaching here means user + // code outside a validator failed (a store subscriber, say). Release the + // submit lock before rethrowing, or `canSubmit` stays false forever. + $formMeta.set({ ...$formMeta.get(), isSubmitting: false, isSubmitted: true, isSubmitSuccessful: false }); + throw error; } - await Promise.all([...fieldInfo.keys()].map(name => validateField(name, 'submit'))); - await validateForm('submit'); - await allTasks(); - const state = $state.get(); const value = $values.get(); if (!state.isValid && options.canSubmitWhenInvalid !== true) { From 8dae0e8865f2ea6a8fa5e3b5cc3fe1c7bb57b188 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 24 Aug 2026 15:08:56 -0400 Subject: [PATCH 5/5] fix(form): validate array indexes and restrict helpers to array fields --- packages/form/src/array/index.test.ts | 104 ++++++++++++++++++++++++++ packages/form/src/array/index.ts | 66 ++++++++++++---- packages/form/src/index.type.test.ts | 31 ++++++++ 3 files changed, 186 insertions(+), 15 deletions(-) create mode 100644 packages/form/src/array/index.test.ts diff --git a/packages/form/src/array/index.test.ts b/packages/form/src/array/index.test.ts new file mode 100644 index 00000000000..413c7416c88 --- /dev/null +++ b/packages/form/src/array/index.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; + +import { createForm } from '../form'; +import { + clearFieldValues, + insertFieldValue, + moveFieldValues, + pushFieldValue, + removeFieldValue, + replaceFieldValue, + swapFieldValues, +} from './index'; + +function listForm() { + return createForm({ defaultValues: { items: ['a', 'b', 'c'] } }); +} + +describe('array field operations', () => { + it('appends, inserts and removes by index', () => { + const form = listForm(); + pushFieldValue(form, 'items', 'd'); + expect(form.state.values.items).toEqual(['a', 'b', 'c', 'd']); + insertFieldValue(form, 'items', 0, 'z'); + expect(form.state.values.items).toEqual(['z', 'a', 'b', 'c', 'd']); + removeFieldValue(form, 'items', 1); + expect(form.state.values.items).toEqual(['z', 'b', 'c', 'd']); + }); + + it('inserts at the end of the array', () => { + const form = listForm(); + insertFieldValue(form, 'items', 3, 'd'); + expect(form.state.values.items).toEqual(['a', 'b', 'c', 'd']); + }); + + it('swaps and moves items', () => { + const form = listForm(); + swapFieldValues(form, 'items', 0, 2); + expect(form.state.values.items).toEqual(['c', 'b', 'a']); + moveFieldValues(form, 'items', 2, 0); + expect(form.state.values.items).toEqual(['a', 'c', 'b']); + }); +}); + +describe('array index validation', () => { + it('rejects an out-of-range replace instead of creating a sparse array', () => { + const form = listForm(); + expect(() => replaceFieldValue(form, 'items', 5, 'x')).toThrow(RangeError); + expect(form.state.values.items).toEqual(['a', 'b', 'c']); + }); + + it('rejects an out-of-range swap instead of writing undefined', () => { + const form = listForm(); + expect(() => swapFieldValues(form, 'items', 0, 9)).toThrow(RangeError); + expect(form.state.values.items).toEqual(['a', 'b', 'c']); + }); + + it('rejects an out-of-range move instead of inserting undefined', () => { + const form = listForm(); + expect(() => moveFieldValues(form, 'items', 9, 0)).toThrow(RangeError); + expect(() => moveFieldValues(form, 'items', 0, 9)).toThrow(RangeError); + expect(form.state.values.items).toEqual(['a', 'b', 'c']); + }); + + it('rejects an out-of-range insert or remove', () => { + const form = listForm(); + expect(() => insertFieldValue(form, 'items', 4, 'x')).toThrow(RangeError); + expect(() => removeFieldValue(form, 'items', 3)).toThrow(RangeError); + expect(form.state.values.items).toEqual(['a', 'b', 'c']); + }); + + it('rejects a non-integer index', () => { + const form = listForm(); + expect(() => replaceFieldValue(form, 'items', 1.5, 'x')).toThrow(RangeError); + expect(() => removeFieldValue(form, 'items', Number.NaN)).toThrow(RangeError); + expect(() => removeFieldValue(form, 'items', -1)).toThrow(RangeError); + }); + + it('rejects any index on an empty array', () => { + const form = createForm({ defaultValues: { items: [] as string[] } }); + expect(() => removeFieldValue(form, 'items', 0)).toThrow(RangeError); + }); +}); + +describe('array operations on non-array fields', () => { + it('rejects a field holding a scalar instead of overwriting it', () => { + const form = createForm({ defaultValues: { age: 30 } }); + // @ts-expect-error 'age' is not an array field; the runtime guard covers JS callers. + expect(() => pushFieldValue(form, 'age', 1)).toThrow(TypeError); + expect(form.state.values.age).toBe(30); + }); + + it('rejects a field holding an object instead of overwriting it', () => { + const form = createForm({ defaultValues: { user: { name: 'bob' } } }); + // @ts-expect-error 'user' is not an array field; the runtime guard covers JS callers. + expect(() => clearFieldValues(form, 'user')).toThrow(TypeError); + expect(form.state.values.user).toEqual({ name: 'bob' }); + }); + + it('treats an absent field as an empty array', () => { + const form = createForm<{ items?: string[] }>({ defaultValues: {} }); + pushFieldValue(form, 'items', 'a'); + expect(form.state.values.items).toEqual(['a']); + }); +}); diff --git a/packages/form/src/array/index.ts b/packages/form/src/array/index.ts index 12a443cd938..6937d78d3af 100644 --- a/packages/form/src/array/index.ts +++ b/packages/form/src/array/index.ts @@ -1,4 +1,13 @@ -import type { FieldName, FormApi } from '../types'; +import type { FieldName, FieldValue, FormApi } from '../types'; + +/** The subset of field paths whose value is an array. */ +export type ArrayFieldName = { + [K in FieldName]: NonNullable> extends readonly unknown[] ? K : never; +}[FieldName]; + +/** The element type of the array at an array field path. */ +type ArrayFieldItem> = + NonNullable> extends readonly (infer Item)[] ? Item : never; /** * Array field operations as standalone, tree-shakeable functions. Import only @@ -25,42 +34,60 @@ function loose(form: unknown): LooseForm { function readArray(form: LooseForm, name: string): unknown[] { const value = form.getFieldValue(name); - return Array.isArray(value) ? value : []; + if (value === undefined || value === null) { + return []; + } + if (!Array.isArray(value)) { + throw new TypeError(`Cannot run an array operation on field "${name}": its value is not an array.`); + } + return value; +} + +/** + * Out-of-range indexes would otherwise reach `splice`/assignment and silently + * produce sparse arrays or `undefined` items, so they are rejected up front. + */ +function checkIndex(name: string, label: string, index: number, max: number): void { + if (!Number.isInteger(index) || index < 0 || index > max) { + throw new RangeError(`${label} index ${index} is out of range for array field "${name}".`); + } } /** Append `value` to the array at `name`. */ -export function pushFieldValue( +export function pushFieldValue>( form: FormApi, - name: FieldName, - value: unknown, + name: Name, + value: ArrayFieldItem, ): void { const f = loose(form); f.setFieldValue(name, [...readArray(f, name), value]); } /** Insert `value` at `index`. */ -export function insertFieldValue( +export function insertFieldValue>( form: FormApi, - name: FieldName, + name: Name, index: number, - value: unknown, + value: ArrayFieldItem, ): void { const f = loose(form); const next = [...readArray(f, name)]; + checkIndex(name, 'Insert', index, next.length); next.splice(index, 0, value); f.setFieldValue(name, next); f._clearChildMeta(name); } /** Replace the item at `index` (no reindex — indices are unchanged). */ -export function replaceFieldValue( +export function replaceFieldValue>( form: FormApi, - name: FieldName, + name: Name, index: number, - value: unknown, + value: ArrayFieldItem, ): void { const f = loose(form); const next = [...readArray(f, name)]; + checkIndex(name, 'Replace', index, next.length - 1); next[index] = value; f.setFieldValue(name, next); } @@ -68,11 +95,12 @@ export function replaceFieldValue( /** Remove the item at `index`. */ export function removeFieldValue( form: FormApi, - name: FieldName, + name: ArrayFieldName, index: number, ): void { const f = loose(form); const next = [...readArray(f, name)]; + checkIndex(name, 'Remove', index, next.length - 1); next.splice(index, 1); f.setFieldValue(name, next); f._clearChildMeta(name); @@ -81,12 +109,14 @@ export function removeFieldValue( /** Swap the items at `a` and `b`. */ export function swapFieldValues( form: FormApi, - name: FieldName, + name: ArrayFieldName, a: number, b: number, ): void { const f = loose(form); const next = [...readArray(f, name)]; + checkIndex(name, 'Swap', a, next.length - 1); + checkIndex(name, 'Swap', b, next.length - 1); [next[a], next[b]] = [next[b], next[a]]; f.setFieldValue(name, next); f._clearChildMeta(name); @@ -95,12 +125,14 @@ export function swapFieldValues( /** Move the item at `from` to `to`. */ export function moveFieldValues( form: FormApi, - name: FieldName, + name: ArrayFieldName, from: number, to: number, ): void { const f = loose(form); const next = [...readArray(f, name)]; + checkIndex(name, 'Move source', from, next.length - 1); + checkIndex(name, 'Move destination', to, next.length - 1); const [item] = next.splice(from, 1); next.splice(to, 0, item); f.setFieldValue(name, next); @@ -108,8 +140,12 @@ export function moveFieldValues( } /** Remove every item from the array at `name`. */ -export function clearFieldValues(form: FormApi, name: FieldName): void { +export function clearFieldValues( + form: FormApi, + name: ArrayFieldName, +): void { const f = loose(form); + readArray(f, name); f.setFieldValue(name, []); f._clearChildMeta(name); } diff --git a/packages/form/src/index.type.test.ts b/packages/form/src/index.type.test.ts index 9355e876033..4a451551082 100644 --- a/packages/form/src/index.type.test.ts +++ b/packages/form/src/index.type.test.ts @@ -1,5 +1,7 @@ import { describe, expectTypeOf, it } from 'vitest'; +import type { ArrayFieldName } from './array'; +import { pushFieldValue } from './array'; import { createForm } from './form'; import type { FieldName, FieldValue } from './types'; @@ -30,3 +32,32 @@ describe('field path typing', () => { expectTypeOf(form.getFieldValue('age')).toEqualTypeOf(); }); }); + +// A form shape deep enough to exercise the recursive path types through the +// array helpers' generics, where TS's instantiation-depth limit bites first. +type Deep = { + email: string; + profile: { name: string; tags: string[]; address: { city: string; zip: string } }; + friends: { name: string; nicknames: string[]; meta: { since: number } }[]; + optional?: string[]; +}; + +describe('array field path typing', () => { + it('narrows field paths to array-valued ones', () => { + expectTypeOf<'friends'>().toMatchTypeOf>(); + expectTypeOf<'profile.tags'>().toMatchTypeOf>(); + expectTypeOf<'optional'>().toMatchTypeOf>(); + expectTypeOf<'email'>().not.toMatchTypeOf>(); + expectTypeOf<'profile'>().not.toMatchTypeOf>(); + }); + + it('types the pushed value as the array element', () => { + const form = createForm({}); + pushFieldValue(form, 'profile.tags', 'a'); + pushFieldValue(form, 'friends', { name: 'bob', nicknames: [], meta: { since: 1 } }); + // @ts-expect-error element must match the array's item type + pushFieldValue(form, 'profile.tags', 1); + // @ts-expect-error 'email' is not an array field + pushFieldValue(form, 'email', 'a'); + }); +});