From 44cd91e7ad20fa59da044670f362c22aa48ef6ca Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 17 Sep 2026 16:58:07 -0400 Subject: [PATCH 01/11] feat(mosaic): add useForm hook Co-Authored-By: Claude --- .changeset/mosaic-use-form.md | 2 + .../src/components/form/form-submit-error.ts | 23 ++ .../src/components/form/form.machine.ts | 80 ++++ .../src/components/form/form.messages.ts | 3 + packages/mosaic/src/components/form/index.ts | 12 + .../form/use-form.edit-password.test.ts | 130 +++++++ .../src/components/form/use-form.test.ts | 341 ++++++++++++++++++ .../mosaic/src/components/form/use-form.ts | 197 ++++++++++ packages/mosaic/src/localization/registry.ts | 2 + 9 files changed, 790 insertions(+) create mode 100644 .changeset/mosaic-use-form.md create mode 100644 packages/mosaic/src/components/form/form-submit-error.ts create mode 100644 packages/mosaic/src/components/form/form.machine.ts create mode 100644 packages/mosaic/src/components/form/form.messages.ts create mode 100644 packages/mosaic/src/components/form/index.ts create mode 100644 packages/mosaic/src/components/form/use-form.edit-password.test.ts create mode 100644 packages/mosaic/src/components/form/use-form.test.ts create mode 100644 packages/mosaic/src/components/form/use-form.ts diff --git a/.changeset/mosaic-use-form.md b/.changeset/mosaic-use-form.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-use-form.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/form/form-submit-error.ts b/packages/mosaic/src/components/form/form-submit-error.ts new file mode 100644 index 00000000000..198d724eb9e --- /dev/null +++ b/packages/mosaic/src/components/form/form-submit-error.ts @@ -0,0 +1,23 @@ +export type FieldFeedbackType = 'error' | 'warning' | 'success' | 'info'; + +export interface FieldFeedback { + type: FieldFeedbackType; + message: string; +} + +export type FormFieldErrors = Partial>; + +export interface FormError { + message?: string; + fields?: FormFieldErrors; +} + +export class FormSubmitError> extends Error { + readonly fields?: FormFieldErrors; + + constructor(message: string, fields?: FormFieldErrors) { + super(message); + this.name = 'FormSubmitError'; + this.fields = fields; + } +} diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts new file mode 100644 index 00000000000..9d6c21cfed0 --- /dev/null +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -0,0 +1,80 @@ +import { setup } from '../../machine/setup'; +import type { FormError } from './form-submit-error'; +import { FormSubmitError } from './form-submit-error'; + +export interface FormContext { + initialValues: TValues; + values: TValues; + error: FormError | undefined; + onSubmit: (values: TValues) => Promise; + canSubmit: (values: TValues) => boolean; + fallbackMessage: string; +} + +export type FormEvent = + | { type: 'CHANGE'; name: keyof TValues; value: TValues[keyof TValues] } + | { type: 'SUBMIT' } + | { type: 'RESET'; values?: TValues }; + +export type FormState = 'editing' | 'submitting'; + +export function toFormError(cause: unknown, fallbackMessage: string): FormError { + if (cause instanceof FormSubmitError) { + return { message: cause.message, fields: cause.fields }; + } + if (cause instanceof Error) { + return { message: cause.message }; + } + return { message: fallbackMessage }; +} + +function withoutField( + error: FormError | undefined, + name: keyof TValues, +): FormError | undefined { + if (error?.fields === undefined) { + return error; + } + const fields = { ...error.fields }; + delete fields[name]; + return { ...error, fields }; +} + +export function createFormMachine(context: FormContext) { + const { createMachine, assign, fromPromise } = setup, FormEvent>(); + + return createMachine({ + id: 'form', + initial: 'editing', + context, + states: { + editing: { + on: { + CHANGE: { + actions: assign((ctx, e) => ({ + values: { ...ctx.values, [e.name]: e.value }, + error: withoutField(ctx.error, e.name), + })), + }, + SUBMIT: { + target: 'submitting', + guard: ctx => ctx.canSubmit(ctx.values), + actions: assign(() => ({ error: undefined })), + }, + RESET: { + actions: assign((ctx, e) => ({ values: e.values ?? ctx.initialValues, error: undefined })), + }, + }, + }, + submitting: { + invoke: fromPromise(ctx => ctx.onSubmit(ctx.values), { + onDone: 'editing', + onError: { + target: 'editing', + actions: assign((ctx, e) => ({ error: toFormError(e.error, ctx.fallbackMessage) })), + }, + }), + }, + }, + }); +} diff --git a/packages/mosaic/src/components/form/form.messages.ts b/packages/mosaic/src/components/form/form.messages.ts new file mode 100644 index 00000000000..96001b60ccb --- /dev/null +++ b/packages/mosaic/src/components/form/form.messages.ts @@ -0,0 +1,3 @@ +export const formMessages = { + error: 'Something went wrong. Please try again.', +}; diff --git a/packages/mosaic/src/components/form/index.ts b/packages/mosaic/src/components/form/index.ts new file mode 100644 index 00000000000..ef6abbc9c01 --- /dev/null +++ b/packages/mosaic/src/components/form/index.ts @@ -0,0 +1,12 @@ +export { FormSubmitError } from './form-submit-error'; +export type { FieldFeedback, FieldFeedbackType, FormError, FormFieldErrors } from './form-submit-error'; +export { useForm } from './use-form'; +export type { + AsyncFieldValidator, + FieldConfig, + FieldsConfig, + FieldValidator, + FormField, + UseFormOptions, + UseFormResult, +} from './use-form'; diff --git a/packages/mosaic/src/components/form/use-form.edit-password.test.ts b/packages/mosaic/src/components/form/use-form.edit-password.test.ts new file mode 100644 index 00000000000..6afb91961c5 --- /dev/null +++ b/packages/mosaic/src/components/form/use-form.edit-password.test.ts @@ -0,0 +1,130 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { FieldFeedback } from './form-submit-error'; +import { FormSubmitError } from './form-submit-error'; +import { useForm } from './use-form'; + +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); + +interface EditPasswordValues { + currentPassword: string; + newPassword: string; + confirmPassword: string; +} + +async function checkStrength(password: string): Promise { + await Promise.resolve(); + if (password.length < 8) { + return { type: 'error', message: 'Your password must contain 8 or more characters.' }; + } + if (!/[0-9]/.test(password)) { + return { type: 'warning', message: 'Your password works, but could be stronger.' }; + } + return { type: 'success', message: 'Your password meets all the necessary requirements.' }; +} + +function useEditPasswordForm(onSubmit: (values: EditPasswordValues) => Promise) { + return useForm({ + initialValues: { currentPassword: '', newPassword: '', confirmPassword: '' }, + fields: { + newPassword: { validateAsync: checkStrength }, + confirmPassword: { + validate: (value, values) => { + if (value === '') { + return undefined; + } + return value === values.newPassword + ? { type: 'success', message: 'Passwords match.' } + : { type: 'error', message: 'Passwords do not match.' }; + }, + }, + }, + onSubmit, + }); +} + +describe('useForm: edit password', () => { + it('walks a user from a weak password to a saved one', async () => { + const onSubmit = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => useEditPasswordForm(onSubmit)); + + act(() => result.current.setValue('currentPassword', 'old-secret')); + act(() => result.current.setValue('newPassword', 'short')); + await act(flush); + expect(result.current.fields.newPassword.feedback).toBeUndefined(); + expect(result.current.canSubmit).toBe(false); + + act(() => result.current.touch('newPassword')); + expect(result.current.fields.newPassword.feedback).toEqual({ + type: 'error', + message: 'Your password must contain 8 or more characters.', + }); + + act(() => result.current.setValue('newPassword', 'longenough')); + expect(result.current.fields.newPassword.isValidating).toBe(true); + await act(flush); + expect(result.current.fields.newPassword.feedback).toEqual({ + type: 'warning', + message: 'Your password works, but could be stronger.', + }); + + act(() => result.current.setValue('newPassword', 'longenough1')); + await act(flush); + expect(result.current.fields.newPassword.feedback).toEqual({ + type: 'success', + message: 'Your password meets all the necessary requirements.', + }); + + act(() => result.current.setValue('confirmPassword', 'longenough')); + expect(result.current.fields.confirmPassword.feedback).toBeUndefined(); + expect(result.current.canSubmit).toBe(false); + act(() => result.current.setValue('confirmPassword', 'longenough1')); + expect(result.current.fields.confirmPassword.feedback).toEqual({ type: 'success', message: 'Passwords match.' }); + expect(result.current.canSubmit).toBe(true); + + act(() => result.current.submit()); + expect(onSubmit).toHaveBeenCalledWith({ + currentPassword: 'old-secret', + newPassword: 'longenough1', + confirmPassword: 'longenough1', + }); + }); + + it('surfaces every error at once when the user submits early', () => { + const onSubmit = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => useEditPasswordForm(onSubmit)); + act(() => result.current.setValue('newPassword', 'abc')); + act(() => result.current.setValue('confirmPassword', 'abd')); + act(() => result.current.submit()); + expect(onSubmit).not.toHaveBeenCalled(); + expect(result.current.fields.confirmPassword.feedback).toEqual({ + type: 'error', + message: 'Passwords do not match.', + }); + }); + + it('shows the server rejection on the banner and under the field the model names', async () => { + const onSubmit = vi.fn(() => + Promise.reject( + new FormSubmitError('Password could not be changed.', { + currentPassword: 'Incorrect password.', + }), + ), + ); + const { result } = renderHook(() => useEditPasswordForm(onSubmit)); + act(() => result.current.setValue('currentPassword', 'wrong')); + act(() => result.current.setValue('newPassword', 'longenough1')); + await act(flush); + act(() => result.current.setValue('confirmPassword', 'longenough1')); + await act(async () => { + result.current.submit(); + await flush(); + }); + expect(result.current.error).toBe('Password could not be changed.'); + expect(result.current.fields.currentPassword.feedback).toEqual({ type: 'error', message: 'Incorrect password.' }); + act(() => result.current.setValue('currentPassword', 'right')); + expect(result.current.fields.currentPassword.feedback).toBeUndefined(); + expect(result.current.canSubmit).toBe(true); + }); +}); diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts new file mode 100644 index 00000000000..1b9f2793ca9 --- /dev/null +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -0,0 +1,341 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; + +import type { FieldFeedback } from './form-submit-error'; +import { FormSubmitError } from './form-submit-error'; +import { useForm } from './use-form'; + +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); +const resolved = () => Promise.resolve(); + +function deferred() { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise(res => { + resolve = res; + }); + return { promise, resolve }; +} + +describe('useForm', () => { + it('starts from initialValues and updates one value at a time', () => { + const { result } = renderHook(() => useForm({ initialValues: { username: 'alex', bio: '' }, onSubmit: resolved })); + expect(result.current.values).toEqual({ username: 'alex', bio: '' }); + expect(result.current.id).toEqual(expect.any(String)); + act(() => result.current.setValue('bio', 'hello')); + expect(result.current.values).toEqual({ username: 'alex', bio: 'hello' }); + }); + + it('types values, fields, setValue, validators and reset from initialValues', () => { + const { result } = renderHook(() => + useForm({ + initialValues: { username: 'alex', age: 1 }, + fields: { + age: { + validate: (value, values) => { + expectTypeOf(value).toEqualTypeOf(); + expectTypeOf(values).toEqualTypeOf<{ username: string; age: number }>(); + return undefined; + }, + }, + }, + onSubmit: resolved, + }), + ); + expectTypeOf(result.current.values).toEqualTypeOf<{ username: string; age: number }>(); + expectTypeOf(result.current.setValue).parameter(0).toEqualTypeOf<'username' | 'age'>(); + expectTypeOf(result.current.touch).parameter(0).toEqualTypeOf<'username' | 'age'>(); + expectTypeOf(result.current.fields.age.feedback).toEqualTypeOf(); + expectTypeOf(result.current.error).toEqualTypeOf(); + expectTypeOf(result.current.reset).parameter(0).toEqualTypeOf<{ username: string; age: number } | undefined>(); + }); + + it('submits the current values once and ignores submits while pending', async () => { + const request = createDeferredPromise(); + const onSubmit = vi.fn(async (_values: { username: string }) => { + await request.promise; + }); + const { result } = renderHook(() => useForm({ initialValues: { username: 'alex' }, onSubmit })); + act(() => result.current.setValue('username', 'alexc')); + act(() => result.current.submit()); + expect(result.current.isSubmitting).toBe(true); + expect(result.current.canSubmit).toBe(false); + act(() => result.current.submit()); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ username: 'alexc' }); + await act(async () => { + request.resolve(); + await flush(); + }); + expect(result.current.isSubmitting).toBe(false); + expect(result.current.error).toBeUndefined(); + }); + + it('submits a value set in the same tick', () => { + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => useForm({ initialValues: { code: '' }, onSubmit })); + act(() => { + result.current.setValue('code', '123456'); + result.current.submit(); + }); + expect(onSubmit).toHaveBeenCalledWith({ code: '123456' }); + }); + + it('maps FormSubmitError onto the form message and field feedback, clearing the field on change', async () => { + const { result } = renderHook(() => + useForm({ + initialValues: { username: 'alex' }, + onSubmit: () => Promise.reject(new FormSubmitError('Could not save', { username: 'Taken' })), + }), + ); + await act(async () => { + result.current.submit(); + await flush(); + }); + expect(result.current.error).toBe('Could not save'); + expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Taken' }); + act(() => result.current.setValue('username', 'alexc')); + expect(result.current.error).toBe('Could not save'); + expect(result.current.fields.username.feedback).toBeUndefined(); + }); + + it('shows only the message for a plain Error and a generic message otherwise', async () => { + const plain = renderHook(() => + useForm({ initialValues: { username: '' }, onSubmit: () => Promise.reject(new Error('Nope')) }), + ); + await act(async () => { + plain.result.current.submit(); + await flush(); + }); + expect(plain.result.current.error).toBe('Nope'); + + const cause: unknown = 'boom'; + const unknown = renderHook(() => + useForm({ + initialValues: { username: '' }, + onSubmit: async () => { + await Promise.resolve(); + throw cause; + }, + }), + ); + await act(async () => { + unknown.result.current.submit(); + await flush(); + }); + expect(unknown.result.current.error).toBe('Something went wrong. Please try again.'); + }); + + it('clears the message on the next submit', async () => { + let fail = true; + const { result } = renderHook(() => + useForm({ + initialValues: { username: '' }, + onSubmit: () => (fail ? Promise.reject(new Error('Nope')) : Promise.resolve()), + }), + ); + await act(async () => { + result.current.submit(); + await flush(); + }); + expect(result.current.error).toBe('Nope'); + fail = false; + act(() => result.current.submit()); + expect(result.current.error).toBeUndefined(); + }); + + it('hides validator errors until the field is touched, then updates them live', () => { + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ + initialValues: { password: '' }, + fields: { + password: { + validate: value => (value.length < 8 ? { type: 'error', message: 'Too short' } : undefined), + }, + }, + onSubmit, + }), + ); + act(() => result.current.setValue('password', 'abc')); + expect(result.current.fields.password.feedback).toBeUndefined(); + expect(result.current.fields.password.touched).toBe(false); + expect(result.current.canSubmit).toBe(false); + act(() => result.current.touch('password')); + expect(result.current.fields.password.touched).toBe(true); + expect(result.current.fields.password.feedback).toEqual({ type: 'error', message: 'Too short' }); + act(() => result.current.setValue('password', 'abcdefgh')); + expect(result.current.fields.password.feedback).toBeUndefined(); + expect(result.current.canSubmit).toBe(true); + }); + + it('shows success, warning and info feedback immediately', () => { + const { result } = renderHook(() => + useForm({ + initialValues: { password: '', confirm: '' }, + fields: { + password: { validate: value => (value ? { type: 'warning', message: 'Could be stronger' } : undefined) }, + confirm: { + validate: (value, values) => + value === '' ? undefined : { type: value === values.password ? 'success' : 'error', message: 'Match?' }, + }, + }, + onSubmit: resolved, + }), + ); + act(() => result.current.setValue('password', 'a')); + expect(result.current.fields.password.feedback).toEqual({ type: 'warning', message: 'Could be stronger' }); + act(() => result.current.setValue('confirm', 'a')); + expect(result.current.fields.confirm.feedback).toEqual({ type: 'success', message: 'Match?' }); + act(() => result.current.setValue('password', 'ab')); + expect(result.current.fields.confirm.feedback).toBeUndefined(); + expect(result.current.canSubmit).toBe(false); + }); + + it('touches every field on submit and does not call onSubmit while a validator fails', () => { + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ + initialValues: { a: '', b: '' }, + fields: { a: { validate: () => ({ type: 'error', message: 'Bad' }) } }, + onSubmit, + }), + ); + act(() => result.current.submit()); + expect(onSubmit).not.toHaveBeenCalled(); + expect(result.current.fields.a.touched).toBe(true); + expect(result.current.fields.b.touched).toBe(true); + expect(result.current.fields.a.feedback).toEqual({ type: 'error', message: 'Bad' }); + }); + + it('gates submit silently with canSubmit()', () => { + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ initialValues: { username: 'alex' }, onSubmit, canSubmit: values => values.username !== 'alex' }), + ); + expect(result.current.canSubmit).toBe(false); + expect(result.current.fields.username.feedback).toBeUndefined(); + act(() => result.current.submit()); + expect(onSubmit).not.toHaveBeenCalled(); + act(() => result.current.setValue('username', 'alexc')); + expect(result.current.canSubmit).toBe(true); + }); + + it('runs async validators on change with the latest result winning', async () => { + const checks = new Map>>(); + const validateAsync = vi.fn((value: string) => { + const check = deferred(); + checks.set(value, check); + return check.promise; + }); + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ initialValues: { password: '' }, fields: { password: { validateAsync } }, onSubmit }), + ); + expect(validateAsync).not.toHaveBeenCalled(); + expect(result.current.fields.password.isValidating).toBe(false); + act(() => result.current.setValue('password', 'a')); + act(() => result.current.setValue('password', 'ab')); + expect(validateAsync).toHaveBeenCalledTimes(2); + expect(result.current.fields.password.isValidating).toBe(true); + expect(result.current.canSubmit).toBe(false); + act(() => result.current.submit()); + expect(onSubmit).not.toHaveBeenCalled(); + await act(async () => { + checks.get('ab')?.resolve({ type: 'success', message: 'Strong' }); + await flush(); + }); + expect(result.current.fields.password.feedback).toEqual({ type: 'success', message: 'Strong' }); + expect(result.current.fields.password.isValidating).toBe(false); + expect(result.current.canSubmit).toBe(true); + await act(async () => { + checks.get('a')?.resolve({ type: 'error', message: 'Weak' }); + await flush(); + }); + expect(result.current.fields.password.feedback).toEqual({ type: 'success', message: 'Strong' }); + }); + + it('skips async validation when the value returns to its initial value', async () => { + const validateAsync = vi.fn(() => Promise.resolve(undefined)); + const { result } = renderHook(() => + useForm({ initialValues: { username: 'alex' }, fields: { username: { validateAsync } }, onSubmit: resolved }), + ); + act(() => result.current.setValue('username', 'alexc')); + expect(validateAsync).toHaveBeenCalledTimes(1); + act(() => result.current.setValue('username', 'alex')); + await act(flush); + expect(validateAsync).toHaveBeenCalledTimes(1); + expect(result.current.fields.username.isValidating).toBe(false); + }); + + it('prefers a sync validator result over the async one for the same field', async () => { + const validateAsync = vi.fn(() => Promise.resolve({ type: 'success', message: 'Strong' })); + const { result } = renderHook(() => + useForm({ + initialValues: { password: '' }, + fields: { + password: { + validate: value => (value.length < 3 ? { type: 'info', message: 'Keep going' } : undefined), + validateAsync, + }, + }, + onSubmit: resolved, + }), + ); + act(() => result.current.setValue('password', 'ab')); + await act(flush); + expect(result.current.fields.password.feedback).toEqual({ type: 'info', message: 'Keep going' }); + act(() => result.current.setValue('password', 'abc')); + await act(flush); + expect(result.current.fields.password.feedback).toEqual({ type: 'success', message: 'Strong' }); + }); + + it('resets to the latest initialValues or to the given values, clearing errors, touched and feedback', async () => { + const { result, rerender } = renderHook( + ({ username }) => + useForm({ + initialValues: { username }, + fields: { username: { validate: () => ({ type: 'error', message: 'Bad' }) } }, + onSubmit: () => Promise.reject(new Error('Nope')), + }), + { initialProps: { username: 'alex' } }, + ); + act(() => result.current.setValue('username', 'draft')); + await act(async () => { + result.current.submit(); + await flush(); + }); + expect(result.current.fields.username.touched).toBe(true); + expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Bad' }); + rerender({ username: 'saved' }); + act(() => result.current.reset()); + expect(result.current.values).toEqual({ username: 'saved' }); + expect(result.current.error).toBeUndefined(); + expect(result.current.fields.username.touched).toBe(false); + expect(result.current.fields.username.feedback).toBeUndefined(); + act(() => result.current.reset({ username: 'given' })); + expect(result.current.values).toEqual({ username: 'given' }); + }); + + it('ignores changes and reset while submitting', async () => { + const request = createDeferredPromise(); + const { result } = renderHook(() => + useForm({ + initialValues: { username: 'alex' }, + onSubmit: async () => { + await request.promise; + }, + }), + ); + act(() => result.current.submit()); + act(() => result.current.setValue('username', 'other')); + act(() => result.current.reset()); + expect(result.current.values).toEqual({ username: 'alex' }); + expect(result.current.isSubmitting).toBe(true); + await act(async () => { + request.resolve(); + await flush(); + }); + expect(result.current.isSubmitting).toBe(false); + }); +}); diff --git a/packages/mosaic/src/components/form/use-form.ts b/packages/mosaic/src/components/form/use-form.ts new file mode 100644 index 00000000000..39436564737 --- /dev/null +++ b/packages/mosaic/src/components/form/use-form.ts @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useId, useRef, useState } from 'react'; + +import { useMessages } from '../../localization'; +import type { StateMachine } from '../../machine/types'; +import { useMachine } from '../../machine/useMachine'; +import type { FormContext, FormEvent } from './form.machine'; +import { createFormMachine } from './form.machine'; +import type { FieldFeedback } from './form-submit-error'; + +export type FieldValidator = ( + value: TValue, + values: TValues, +) => FieldFeedback | undefined; + +export type AsyncFieldValidator = ( + value: TValue, + values: TValues, +) => Promise; + +export interface FieldConfig { + validate?: FieldValidator; + validateAsync?: AsyncFieldValidator; +} + +export type FieldsConfig = { [K in keyof TValues]?: FieldConfig }; + +export interface UseFormOptions { + initialValues: TValues; + fields?: FieldsConfig; + onSubmit: (values: TValues) => Promise; + canSubmit?: (values: TValues) => boolean; +} + +export interface FormField { + feedback: FieldFeedback | undefined; + isValidating: boolean; + touched: boolean; +} + +export interface UseFormResult { + id: string; + values: TValues; + fields: Record; + error: string | undefined; + isSubmitting: boolean; + canSubmit: boolean; + setValue: (name: K, value: TValues[K]) => void; + touch: (name: keyof TValues) => void; + submit: () => void; + reset: (values?: TValues) => void; +} + +interface AsyncFieldState { + value: unknown; + feedback: FieldFeedback | undefined; + pending: boolean; +} + +type AsyncState = Partial>; +type Touched = Partial>; + +const always = () => true; + +function keysOf(value: T): (keyof T)[]; +function keysOf(value: object): string[] { + return Object.keys(value); +} + +function mapKeys(value: T, fn: (key: keyof T) => U): Record; +function mapKeys(value: object, fn: (key: string) => unknown): Record { + return Object.fromEntries(Object.keys(value).map(key => [key, fn(key)])); +} + +function runSyncValidator( + fields: FieldsConfig | undefined, + name: K, + values: TValues, +): FieldFeedback | undefined { + return fields?.[name]?.validate?.(values[name], values); +} + +function rawFeedback( + fields: FieldsConfig | undefined, + name: keyof TValues, + values: TValues, + submitErrors: Partial> | undefined, + async: AsyncState, +): FieldFeedback | undefined { + const submitError = submitErrors?.[name]; + if (submitError !== undefined) { + return { type: 'error', message: submitError }; + } + return runSyncValidator(fields, name, values) ?? async[name]?.feedback; +} + +function isBlocked( + fields: FieldsConfig | undefined, + values: TValues, + async: AsyncState, +): boolean { + return keysOf(values).some(name => { + const state = async[name]; + return state?.pending === true || rawFeedback(fields, name, values, undefined, async)?.type === 'error'; + }); +} + +export function useForm(options: UseFormOptions): UseFormResult { + const id = useId(); + const m = useMessages('form'); + const [touched, setTouched] = useState>({}); + const [async, setAsync] = useState>({}); + const asyncRef = useRef(async); + asyncRef.current = async; + const optionsRef = useRef(options); + optionsRef.current = options; + + const canSubmitValues = useCallback((values: TValues) => { + const { fields, canSubmit = always } = optionsRef.current; + return canSubmit(values) && !isBlocked(fields, values, asyncRef.current); + }, []); + + const deps: Omit, 'values' | 'error'> = { + initialValues: options.initialValues, + onSubmit: options.onSubmit, + canSubmit: canSubmitValues, + fallbackMessage: m.error, + }; + const machineRef = useRef, FormEvent> | null>(null); + if (machineRef.current === null) { + machineRef.current = createFormMachine({ ...deps, values: options.initialValues, error: undefined }); + } + const [snapshot, send] = useMachine(machineRef.current, { context: deps }); + const { values } = snapshot.context; + + useEffect(() => { + const { fields, initialValues } = optionsRef.current; + for (const name of keysOf(values)) { + const validateAsync = fields?.[name]?.validateAsync; + const value = values[name]; + if (validateAsync === undefined || asyncRef.current[name]?.value === value) { + continue; + } + if (value === initialValues[name]) { + setAsync(current => ({ ...current, [name]: undefined })); + continue; + } + setAsync(current => ({ ...current, [name]: { value, feedback: undefined, pending: true } })); + void validateAsync(value, values).then(feedback => { + setAsync(current => + current[name]?.value === value ? { ...current, [name]: { value, feedback, pending: false } } : current, + ); + }); + } + }, [values]); + + const setValue = useCallback( + (name: K, value: TValues[K]) => send({ type: 'CHANGE', name, value }), + [send], + ); + const touch = useCallback((name: keyof TValues) => setTouched(current => ({ ...current, [name]: true })), []); + const submit = useCallback(() => { + setTouched(mapKeys(optionsRef.current.initialValues, () => true)); + send({ type: 'SUBMIT' }); + }, [send]); + const reset = useCallback( + (nextValues?: TValues) => { + setTouched({}); + setAsync({}); + send({ type: 'RESET', values: nextValues }); + }, + [send], + ); + + const isSubmitting = snapshot.value === 'submitting'; + const fields = mapKeys(values, (name): FormField => { + const feedback = rawFeedback(options.fields, name, values, snapshot.context.error?.fields, async); + const isTouched = touched[name] === true; + return { + feedback: feedback?.type === 'error' && !isTouched ? undefined : feedback, + isValidating: async[name]?.pending === true, + touched: isTouched, + }; + }); + + return { + id, + values, + fields, + error: snapshot.context.error?.message, + isSubmitting, + canSubmit: !isSubmitting && canSubmitValues(values), + setValue, + touch, + submit, + reset, + }; +} diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index f4a40d2c76d..222e21bd691 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -1,3 +1,4 @@ +import { formMessages } from '../components/form/form.messages'; import { organizationProfileMessages } from '../features/organization-profile/organization-profile.messages'; import { organizationProfileApiKeysPanelMessages } from '../features/organization-profile/organization-profile-api-keys-panel.messages'; import { organizationProfileDangerSectionMessages } from '../features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.messages'; @@ -24,6 +25,7 @@ import { userProfilePasswordSectionMessages } from '../features/user-profile/use import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets.messages'; export const mosaicMessages = { + form: formMessages, organizationProfile: organizationProfileMessages, organizationProfileDangerSection: organizationProfileDangerSectionMessages, organizationProfileWorkspaceSection: organizationProfileWorkspaceSectionMessages, From ff98fd1c3865bfbd9e9511f9a126538c432b553d Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 18 Sep 2026 08:30:05 -0400 Subject: [PATCH 02/11] feat(mosaic): add register, dirty tracking and submit handler to useForm --- packages/mosaic/src/components/form/index.ts | 2 + .../src/components/form/use-form.test.ts | 64 +++++++++++++ .../mosaic/src/components/form/use-form.ts | 90 +++++++++++++++++-- 3 files changed, 147 insertions(+), 9 deletions(-) diff --git a/packages/mosaic/src/components/form/index.ts b/packages/mosaic/src/components/form/index.ts index ef6abbc9c01..406d2552330 100644 --- a/packages/mosaic/src/components/form/index.ts +++ b/packages/mosaic/src/components/form/index.ts @@ -7,6 +7,8 @@ export type { FieldsConfig, FieldValidator, FormField, + RegisteredField, + TextFieldName, UseFormOptions, UseFormResult, } from './use-form'; diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts index 1b9f2793ca9..081bbbff9ca 100644 --- a/packages/mosaic/src/components/form/use-form.test.ts +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -45,6 +45,7 @@ describe('useForm', () => { expectTypeOf(result.current.values).toEqualTypeOf<{ username: string; age: number }>(); expectTypeOf(result.current.setValue).parameter(0).toEqualTypeOf<'username' | 'age'>(); expectTypeOf(result.current.touch).parameter(0).toEqualTypeOf<'username' | 'age'>(); + expectTypeOf(result.current.register).parameter(0).toEqualTypeOf<'username'>(); expectTypeOf(result.current.fields.age.feedback).toEqualTypeOf(); expectTypeOf(result.current.error).toEqualTypeOf(); expectTypeOf(result.current.reset).parameter(0).toEqualTypeOf<{ username: string; age: number } | undefined>(); @@ -338,4 +339,67 @@ describe('useForm', () => { }); expect(result.current.isSubmitting).toBe(false); }); + + it('registers a text control with its name, value, change and blur handlers', () => { + const { result } = renderHook(() => useForm({ initialValues: { username: 'alex' }, onSubmit: resolved })); + expect(result.current.register('username')).toMatchObject({ name: 'username', value: 'alex' }); + act(() => result.current.register('username').onChange({ target: { value: 'alexc' } })); + expect(result.current.values.username).toBe('alexc'); + expect(result.current.register('username').value).toBe('alexc'); + expect(result.current.fields.username.touched).toBe(false); + act(() => result.current.register('username').onBlur()); + expect(result.current.fields.username.touched).toBe(true); + }); + + it('focuses the first registered control with an error instead of submitting', () => { + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ + initialValues: { a: '', b: '' }, + fields: { b: { validate: value => (value === '' ? { type: 'error', message: 'Required' } : undefined) } }, + onSubmit, + }), + ); + const a = document.body.appendChild(document.createElement('input')); + const b = document.body.appendChild(document.createElement('input')); + result.current.register('a').ref(a); + result.current.register('b').ref(b); + act(() => result.current.submit()); + expect(onSubmit).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(b); + act(() => result.current.setValue('b', 'ok')); + a.focus(); + act(() => result.current.submit()); + expect(onSubmit).toHaveBeenCalledWith({ a: '', b: 'ok' }); + expect(document.activeElement).toBe(a); + a.remove(); + b.remove(); + }); + + it('marks fields and the form dirty against initialValues, or the values given to reset', () => { + const { result } = renderHook(() => useForm({ initialValues: { username: 'alex', bio: '' }, onSubmit: resolved })); + expect(result.current.isDirty).toBe(false); + act(() => result.current.setValue('bio', 'hi')); + expect(result.current.fields.bio.isDirty).toBe(true); + expect(result.current.fields.username.isDirty).toBe(false); + expect(result.current.isDirty).toBe(true); + act(() => result.current.setValue('bio', '')); + expect(result.current.isDirty).toBe(false); + act(() => result.current.reset({ username: 'sam', bio: 'x' })); + expect(result.current.isDirty).toBe(false); + act(() => result.current.setValue('bio', '')); + expect(result.current.fields.bio.isDirty).toBe(true); + act(() => result.current.reset()); + expect(result.current.values).toEqual({ username: 'alex', bio: '' }); + expect(result.current.isDirty).toBe(false); + }); + + it('handles a form submit event by preventing navigation and submitting', () => { + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => useForm({ initialValues: { username: 'alex' }, onSubmit })); + const preventDefault = vi.fn(); + act(() => result.current.handleSubmit({ preventDefault })); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(onSubmit).toHaveBeenCalledWith({ username: 'alex' }); + }); }); diff --git a/packages/mosaic/src/components/form/use-form.ts b/packages/mosaic/src/components/form/use-form.ts index 39436564737..a2251700eed 100644 --- a/packages/mosaic/src/components/form/use-form.ts +++ b/packages/mosaic/src/components/form/use-form.ts @@ -35,6 +35,20 @@ export interface FormField { feedback: FieldFeedback | undefined; isValidating: boolean; touched: boolean; + isDirty: boolean; +} + +export type TextFieldName = { + [K in keyof TValues]: string extends TValues[K] ? K : never; +}[keyof TValues] & + string; + +export interface RegisteredField { + name: K; + value: TValues[K]; + onChange: (event: { target: { value: TValues[K] } }) => void; + onBlur: () => void; + ref: (element: HTMLElement | null) => void; } export interface UseFormResult { @@ -43,10 +57,13 @@ export interface UseFormResult { fields: Record; error: string | undefined; isSubmitting: boolean; + isDirty: boolean; canSubmit: boolean; + register: >(name: K) => RegisteredField; setValue: (name: K, value: TValues[K]) => void; touch: (name: keyof TValues) => void; submit: () => void; + handleSubmit: (event: { preventDefault: () => void }) => void; reset: (values?: TValues) => void; } @@ -58,6 +75,7 @@ interface AsyncFieldState { type AsyncState = Partial>; type Touched = Partial>; +type ElementRef = (element: HTMLElement | null) => void; const always = () => true; @@ -93,15 +111,22 @@ function rawFeedback( return runSyncValidator(fields, name, values) ?? async[name]?.feedback; } +function firstInvalid( + fields: FieldsConfig | undefined, + values: TValues, + async: AsyncState, +): keyof TValues | undefined { + return keysOf(values).find(name => rawFeedback(fields, name, values, undefined, async)?.type === 'error'); +} + function isBlocked( fields: FieldsConfig | undefined, values: TValues, async: AsyncState, ): boolean { - return keysOf(values).some(name => { - const state = async[name]; - return state?.pending === true || rawFeedback(fields, name, values, undefined, async)?.type === 'error'; - }); + return ( + keysOf(values).some(name => async[name]?.pending === true) || firstInvalid(fields, values, async) !== undefined + ); } export function useForm(options: UseFormOptions): UseFormResult { @@ -109,10 +134,16 @@ export function useForm(options: UseFormOptions const m = useMessages('form'); const [touched, setTouched] = useState>({}); const [async, setAsync] = useState>({}); + const [baseline, setBaseline] = useState(undefined); + const initial = baseline ?? options.initialValues; const asyncRef = useRef(async); asyncRef.current = async; const optionsRef = useRef(options); optionsRef.current = options; + const initialRef = useRef(initial); + initialRef.current = initial; + const elements = useRef(new Map()); + const refs = useRef(new Map()); const canSubmitValues = useCallback((values: TValues) => { const { fields, canSubmit = always } = optionsRef.current; @@ -129,18 +160,18 @@ export function useForm(options: UseFormOptions if (machineRef.current === null) { machineRef.current = createFormMachine({ ...deps, values: options.initialValues, error: undefined }); } - const [snapshot, send] = useMachine(machineRef.current, { context: deps }); + const [snapshot, send, actor] = useMachine(machineRef.current, { context: deps }); const { values } = snapshot.context; useEffect(() => { - const { fields, initialValues } = optionsRef.current; + const { fields } = optionsRef.current; for (const name of keysOf(values)) { const validateAsync = fields?.[name]?.validateAsync; const value = values[name]; if (validateAsync === undefined || asyncRef.current[name]?.value === value) { continue; } - if (value === initialValues[name]) { + if (value === initialRef.current[name]) { setAsync(current => ({ ...current, [name]: undefined })); continue; } @@ -158,19 +189,56 @@ export function useForm(options: UseFormOptions [send], ); const touch = useCallback((name: keyof TValues) => setTouched(current => ({ ...current, [name]: true })), []); + const refFor = useCallback((name: keyof TValues): ElementRef => { + const existing = refs.current.get(name); + if (existing !== undefined) { + return existing; + } + const ref: ElementRef = element => { + if (element === null) { + elements.current.delete(name); + } else { + elements.current.set(name, element); + } + }; + refs.current.set(name, ref); + return ref; + }, []); const submit = useCallback(() => { - setTouched(mapKeys(optionsRef.current.initialValues, () => true)); + const { fields, initialValues } = optionsRef.current; + setTouched(mapKeys(initialValues, () => true)); + const invalid = firstInvalid(fields, actor.getSnapshot().context.values, asyncRef.current); + if (invalid !== undefined) { + elements.current.get(invalid)?.focus(); + return; + } send({ type: 'SUBMIT' }); - }, [send]); + }, [actor, send]); + const handleSubmit = useCallback( + (event: { preventDefault: () => void }) => { + event.preventDefault(); + submit(); + }, + [submit], + ); const reset = useCallback( (nextValues?: TValues) => { setTouched({}); setAsync({}); + setBaseline(nextValues); send({ type: 'RESET', values: nextValues }); }, [send], ); + const register = >(name: K): RegisteredField => ({ + name, + value: values[name], + onChange: event => setValue(name, event.target.value), + onBlur: () => touch(name), + ref: refFor(name), + }); + const isSubmitting = snapshot.value === 'submitting'; const fields = mapKeys(values, (name): FormField => { const feedback = rawFeedback(options.fields, name, values, snapshot.context.error?.fields, async); @@ -179,6 +247,7 @@ export function useForm(options: UseFormOptions feedback: feedback?.type === 'error' && !isTouched ? undefined : feedback, isValidating: async[name]?.pending === true, touched: isTouched, + isDirty: !Object.is(values[name], initial[name]), }; }); @@ -188,10 +257,13 @@ export function useForm(options: UseFormOptions fields, error: snapshot.context.error?.message, isSubmitting, + isDirty: keysOf(values).some(name => fields[name].isDirty), canSubmit: !isSubmitting && canSubmitValues(values), + register, setValue, touch, submit, + handleSubmit, reset, }; } From ff70aeac639a9da366602c342032c3c6c3aa3da8 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 13:29:12 -0400 Subject: [PATCH 03/11] refactor(mosaic): move form state into the form machine --- .../src/components/form/form.machine.ts | 135 +++++++++++--- packages/mosaic/src/components/form/index.ts | 13 +- .../src/components/form/use-form.test.ts | 5 +- .../mosaic/src/components/form/use-form.ts | 172 ++++-------------- packages/mosaic/src/utils/object.ts | 9 + 5 files changed, 158 insertions(+), 176 deletions(-) create mode 100644 packages/mosaic/src/utils/object.ts diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index 9d6c21cfed0..c3e7599364a 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -1,24 +1,86 @@ import { setup } from '../../machine/setup'; -import type { FormError } from './form-submit-error'; +import { keysOf, mapKeys } from '../../utils/object'; +import type { FieldFeedback, FormError } from './form-submit-error'; import { FormSubmitError } from './form-submit-error'; -export interface FormContext { +export type FieldValidator = ( + value: TValue, + values: TValues, +) => FieldFeedback | undefined; + +export type AsyncFieldValidator = ( + value: TValue, + values: TValues, +) => Promise; + +export interface FieldConfig { + validate?: FieldValidator; + validateAsync?: AsyncFieldValidator; +} + +export type FieldsConfig = { [K in keyof TValues]?: FieldConfig }; + +export interface AsyncFieldState { + value: unknown; + feedback: FieldFeedback | undefined; + pending: boolean; +} + +export interface FormDeps { initialValues: TValues; - values: TValues; - error: FormError | undefined; + fields: FieldsConfig | undefined; onSubmit: (values: TValues) => Promise; canSubmit: (values: TValues) => boolean; fallbackMessage: string; } +export interface FormContext extends FormDeps { + values: TValues; + baseline: TValues | undefined; + touched: Partial>; + async: Partial>; + error: FormError | undefined; +} + export type FormEvent = | { type: 'CHANGE'; name: keyof TValues; value: TValues[keyof TValues] } + | { type: 'TOUCH'; name: keyof TValues } + | { type: 'VALIDATED'; name: keyof TValues; value: unknown; feedback: FieldFeedback | undefined } | { type: 'SUBMIT' } | { type: 'RESET'; values?: TValues }; -export type FormState = 'editing' | 'submitting'; +export function initialOf(context: FormContext): TValues { + return context.baseline ?? context.initialValues; +} + +export function validatorFeedback( + context: FormContext, + name: keyof TValues, +): FieldFeedback | undefined { + return context.fields?.[name]?.validate?.(context.values[name], context.values) ?? context.async[name]?.feedback; +} + +export function fieldFeedback( + context: FormContext, + name: keyof TValues, +): FieldFeedback | undefined { + const submitError = context.error?.fields?.[name]; + return submitError === undefined ? validatorFeedback(context, name) : { type: 'error', message: submitError }; +} + +export function firstInvalid(context: FormContext): keyof TValues | undefined { + return keysOf(context.values).find(name => validatorFeedback(context, name)?.type === 'error'); +} -export function toFormError(cause: unknown, fallbackMessage: string): FormError { +export function isSubmittable(context: FormContext): boolean { + return ( + context.canSubmit(context.values) && + !keysOf(context.values).some(name => context.async[name]?.pending === true) && + firstInvalid(context) === undefined + ); +} + +function toFormError(cause: unknown, fallbackMessage: string): FormError { if (cause instanceof FormSubmitError) { return { message: cause.message, fields: cause.fields }; } @@ -40,30 +102,61 @@ function withoutField( return { ...error, fields }; } -export function createFormMachine(context: FormContext) { +function asyncStateFor( + context: FormContext, + name: keyof TValues, + value: TValues[keyof TValues], +): AsyncFieldState | undefined { + if (context.fields?.[name]?.validateAsync === undefined || value === initialOf(context)[name]) { + return undefined; + } + return { value, feedback: undefined, pending: true }; +} + +export function createFormMachine(deps: FormDeps) { const { createMachine, assign, fromPromise } = setup, FormEvent>(); return createMachine({ id: 'form', initial: 'editing', - context, + context: { ...deps, values: deps.initialValues, baseline: undefined, touched: {}, async: {}, error: undefined }, states: { editing: { on: { - CHANGE: { - actions: assign((ctx, e) => ({ - values: { ...ctx.values, [e.name]: e.value }, - error: withoutField(ctx.error, e.name), - })), - }, - SUBMIT: { - target: 'submitting', - guard: ctx => ctx.canSubmit(ctx.values), - actions: assign(() => ({ error: undefined })), - }, - RESET: { - actions: assign((ctx, e) => ({ values: e.values ?? ctx.initialValues, error: undefined })), + CHANGE: ({ context, event }) => ({ + context: { + values: { ...context.values, [event.name]: event.value }, + async: { ...context.async, [event.name]: asyncStateFor(context, event.name, event.value) }, + error: withoutField(context.error, event.name), + }, + }), + TOUCH: ({ context, event }) => ({ context: { touched: { ...context.touched, [event.name]: true } } }), + VALIDATED: ({ context, event }) => + context.async[event.name]?.value === event.value + ? { + context: { + async: { + ...context.async, + [event.name]: { value: event.value, feedback: event.feedback, pending: false }, + }, + }, + } + : undefined, + SUBMIT: ({ context }) => { + const touched = mapKeys(context.values, (): true => true); + return isSubmittable(context) + ? { target: 'submitting', context: { touched, error: undefined } } + : { context: { touched } }; }, + RESET: ({ context, event }) => ({ + context: { + values: event.values ?? context.initialValues, + baseline: event.values, + touched: {}, + async: {}, + error: undefined, + }, + }), }, }, submitting: { diff --git a/packages/mosaic/src/components/form/index.ts b/packages/mosaic/src/components/form/index.ts index 406d2552330..7205ca5d0ef 100644 --- a/packages/mosaic/src/components/form/index.ts +++ b/packages/mosaic/src/components/form/index.ts @@ -1,14 +1,5 @@ +export type { AsyncFieldValidator, FieldConfig, FieldsConfig, FieldValidator } from './form.machine'; export { FormSubmitError } from './form-submit-error'; export type { FieldFeedback, FieldFeedbackType, FormError, FormFieldErrors } from './form-submit-error'; export { useForm } from './use-form'; -export type { - AsyncFieldValidator, - FieldConfig, - FieldsConfig, - FieldValidator, - FormField, - RegisteredField, - TextFieldName, - UseFormOptions, - UseFormResult, -} from './use-form'; +export type { FormField, RegisteredField, TextFieldName, UseFormOptions, UseFormResult } from './use-form'; diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts index 081bbbff9ca..19771811cae 100644 --- a/packages/mosaic/src/components/form/use-form.test.ts +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -1,4 +1,3 @@ -import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook } from '@testing-library/react'; import { describe, expect, expectTypeOf, it, vi } from 'vitest'; @@ -52,7 +51,7 @@ describe('useForm', () => { }); it('submits the current values once and ignores submits while pending', async () => { - const request = createDeferredPromise(); + const request = deferred(); const onSubmit = vi.fn(async (_values: { username: string }) => { await request.promise; }); @@ -319,7 +318,7 @@ describe('useForm', () => { }); it('ignores changes and reset while submitting', async () => { - const request = createDeferredPromise(); + const request = deferred(); const { result } = renderHook(() => useForm({ initialValues: { username: 'alex' }, diff --git a/packages/mosaic/src/components/form/use-form.ts b/packages/mosaic/src/components/form/use-form.ts index a2251700eed..e3d9ea57a87 100644 --- a/packages/mosaic/src/components/form/use-form.ts +++ b/packages/mosaic/src/components/form/use-form.ts @@ -1,29 +1,13 @@ -import { useCallback, useEffect, useId, useRef, useState } from 'react'; +import { useCallback, useId, useRef } from 'react'; import { useMessages } from '../../localization'; import type { StateMachine } from '../../machine/types'; import { useMachine } from '../../machine/useMachine'; -import type { FormContext, FormEvent } from './form.machine'; -import { createFormMachine } from './form.machine'; +import { keysOf, mapKeys } from '../../utils/object'; +import type { FieldsConfig, FormContext, FormEvent } from './form.machine'; +import { createFormMachine, fieldFeedback, firstInvalid, initialOf, isSubmittable } from './form.machine'; import type { FieldFeedback } from './form-submit-error'; -export type FieldValidator = ( - value: TValue, - values: TValues, -) => FieldFeedback | undefined; - -export type AsyncFieldValidator = ( - value: TValue, - values: TValues, -) => Promise; - -export interface FieldConfig { - validate?: FieldValidator; - validateAsync?: AsyncFieldValidator; -} - -export type FieldsConfig = { [K in keyof TValues]?: FieldConfig }; - export interface UseFormOptions { initialValues: TValues; fields?: FieldsConfig; @@ -67,128 +51,44 @@ export interface UseFormResult { reset: (values?: TValues) => void; } -interface AsyncFieldState { - value: unknown; - feedback: FieldFeedback | undefined; - pending: boolean; -} - -type AsyncState = Partial>; -type Touched = Partial>; type ElementRef = (element: HTMLElement | null) => void; const always = () => true; -function keysOf(value: T): (keyof T)[]; -function keysOf(value: object): string[] { - return Object.keys(value); -} - -function mapKeys(value: T, fn: (key: keyof T) => U): Record; -function mapKeys(value: object, fn: (key: string) => unknown): Record { - return Object.fromEntries(Object.keys(value).map(key => [key, fn(key)])); -} - -function runSyncValidator( - fields: FieldsConfig | undefined, - name: K, - values: TValues, -): FieldFeedback | undefined { - return fields?.[name]?.validate?.(values[name], values); -} - -function rawFeedback( - fields: FieldsConfig | undefined, - name: keyof TValues, - values: TValues, - submitErrors: Partial> | undefined, - async: AsyncState, -): FieldFeedback | undefined { - const submitError = submitErrors?.[name]; - if (submitError !== undefined) { - return { type: 'error', message: submitError }; - } - return runSyncValidator(fields, name, values) ?? async[name]?.feedback; -} - -function firstInvalid( - fields: FieldsConfig | undefined, - values: TValues, - async: AsyncState, -): keyof TValues | undefined { - return keysOf(values).find(name => rawFeedback(fields, name, values, undefined, async)?.type === 'error'); -} - -function isBlocked( - fields: FieldsConfig | undefined, - values: TValues, - async: AsyncState, -): boolean { - return ( - keysOf(values).some(name => async[name]?.pending === true) || firstInvalid(fields, values, async) !== undefined - ); -} - export function useForm(options: UseFormOptions): UseFormResult { const id = useId(); const m = useMessages('form'); - const [touched, setTouched] = useState>({}); - const [async, setAsync] = useState>({}); - const [baseline, setBaseline] = useState(undefined); - const initial = baseline ?? options.initialValues; - const asyncRef = useRef(async); - asyncRef.current = async; - const optionsRef = useRef(options); - optionsRef.current = options; - const initialRef = useRef(initial); - initialRef.current = initial; const elements = useRef(new Map()); const refs = useRef(new Map()); - const canSubmitValues = useCallback((values: TValues) => { - const { fields, canSubmit = always } = optionsRef.current; - return canSubmit(values) && !isBlocked(fields, values, asyncRef.current); - }, []); - - const deps: Omit, 'values' | 'error'> = { + const deps = { initialValues: options.initialValues, + fields: options.fields, onSubmit: options.onSubmit, - canSubmit: canSubmitValues, + canSubmit: options.canSubmit ?? always, fallbackMessage: m.error, }; const machineRef = useRef, FormEvent> | null>(null); if (machineRef.current === null) { - machineRef.current = createFormMachine({ ...deps, values: options.initialValues, error: undefined }); + machineRef.current = createFormMachine(deps); } const [snapshot, send, actor] = useMachine(machineRef.current, { context: deps }); - const { values } = snapshot.context; + const { context } = snapshot; + const { values } = context; - useEffect(() => { - const { fields } = optionsRef.current; - for (const name of keysOf(values)) { + const setValue = useCallback( + (name: K, value: TValues[K]) => { + send({ type: 'CHANGE', name, value }); + const { async, fields, values: next } = actor.getSnapshot().context; const validateAsync = fields?.[name]?.validateAsync; - const value = values[name]; - if (validateAsync === undefined || asyncRef.current[name]?.value === value) { - continue; - } - if (value === initialRef.current[name]) { - setAsync(current => ({ ...current, [name]: undefined })); - continue; + if (validateAsync === undefined || async[name]?.pending !== true || async[name].value !== value) { + return; } - setAsync(current => ({ ...current, [name]: { value, feedback: undefined, pending: true } })); - void validateAsync(value, values).then(feedback => { - setAsync(current => - current[name]?.value === value ? { ...current, [name]: { value, feedback, pending: false } } : current, - ); - }); - } - }, [values]); - - const setValue = useCallback( - (name: K, value: TValues[K]) => send({ type: 'CHANGE', name, value }), - [send], + void validateAsync(value, next).then(feedback => send({ type: 'VALIDATED', name, value, feedback })); + }, + [actor, send], ); - const touch = useCallback((name: keyof TValues) => setTouched(current => ({ ...current, [name]: true })), []); + const touch = useCallback((name: keyof TValues) => send({ type: 'TOUCH', name }), [send]); const refFor = useCallback((name: keyof TValues): ElementRef => { const existing = refs.current.get(name); if (existing !== undefined) { @@ -205,14 +105,11 @@ export function useForm(options: UseFormOptions return ref; }, []); const submit = useCallback(() => { - const { fields, initialValues } = optionsRef.current; - setTouched(mapKeys(initialValues, () => true)); - const invalid = firstInvalid(fields, actor.getSnapshot().context.values, asyncRef.current); + send({ type: 'SUBMIT' }); + const invalid = firstInvalid(actor.getSnapshot().context); if (invalid !== undefined) { elements.current.get(invalid)?.focus(); - return; } - send({ type: 'SUBMIT' }); }, [actor, send]); const handleSubmit = useCallback( (event: { preventDefault: () => void }) => { @@ -221,15 +118,7 @@ export function useForm(options: UseFormOptions }, [submit], ); - const reset = useCallback( - (nextValues?: TValues) => { - setTouched({}); - setAsync({}); - setBaseline(nextValues); - send({ type: 'RESET', values: nextValues }); - }, - [send], - ); + const reset = useCallback((nextValues?: TValues) => send({ type: 'RESET', values: nextValues }), [send]); const register = >(name: K): RegisteredField => ({ name, @@ -240,13 +129,14 @@ export function useForm(options: UseFormOptions }); const isSubmitting = snapshot.value === 'submitting'; + const initial = initialOf(context); const fields = mapKeys(values, (name): FormField => { - const feedback = rawFeedback(options.fields, name, values, snapshot.context.error?.fields, async); - const isTouched = touched[name] === true; + const feedback = fieldFeedback(context, name); + const touched = context.touched[name] === true; return { - feedback: feedback?.type === 'error' && !isTouched ? undefined : feedback, - isValidating: async[name]?.pending === true, - touched: isTouched, + feedback: feedback?.type === 'error' && !touched ? undefined : feedback, + isValidating: context.async[name]?.pending === true, + touched, isDirty: !Object.is(values[name], initial[name]), }; }); @@ -255,10 +145,10 @@ export function useForm(options: UseFormOptions id, values, fields, - error: snapshot.context.error?.message, + error: context.error?.message, isSubmitting, isDirty: keysOf(values).some(name => fields[name].isDirty), - canSubmit: !isSubmitting && canSubmitValues(values), + canSubmit: !isSubmitting && isSubmittable(context), register, setValue, touch, diff --git a/packages/mosaic/src/utils/object.ts b/packages/mosaic/src/utils/object.ts new file mode 100644 index 00000000000..7bbc1f5cea3 --- /dev/null +++ b/packages/mosaic/src/utils/object.ts @@ -0,0 +1,9 @@ +export function keysOf(value: T): (keyof T)[]; +export function keysOf(value: object): string[] { + return Object.keys(value); +} + +export function mapKeys(value: T, fn: (key: keyof T) => U): Record; +export function mapKeys(value: object, fn: (key: string) => unknown): Record { + return Object.fromEntries(Object.keys(value).map(key => [key, fn(key)])); +} From 47e1352aa67ee1b2f956fdca849e54b6e5230db2 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 13:49:19 -0400 Subject: [PATCH 04/11] feat(mosaic): support fields-only submit errors and value-shaped controls in useForm --- .../src/components/form/form-submit-error.ts | 8 +++-- .../src/components/form/form.machine.ts | 2 +- packages/mosaic/src/components/form/index.ts | 9 +++++- .../form/use-form.edit-password.test.ts | 5 +-- .../src/components/form/use-form.test.ts | 31 ++++++++++++++++++- .../mosaic/src/components/form/use-form.ts | 17 ++++++++++ 6 files changed, 64 insertions(+), 8 deletions(-) diff --git a/packages/mosaic/src/components/form/form-submit-error.ts b/packages/mosaic/src/components/form/form-submit-error.ts index 198d724eb9e..de83640f8c9 100644 --- a/packages/mosaic/src/components/form/form-submit-error.ts +++ b/packages/mosaic/src/components/form/form-submit-error.ts @@ -13,11 +13,13 @@ export interface FormError { } export class FormSubmitError> extends Error { - readonly fields?: FormFieldErrors; + readonly banner: string | undefined; + readonly fields: FormFieldErrors | undefined; - constructor(message: string, fields?: FormFieldErrors) { - super(message); + constructor({ message, fields }: FormError) { + super(message ?? Object.values(fields ?? {}).join(' ')); this.name = 'FormSubmitError'; + this.banner = message; this.fields = fields; } } diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index c3e7599364a..1625a788ade 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -82,7 +82,7 @@ export function isSubmittable(context: FormContext(cause: unknown, fallbackMessage: string): FormError { if (cause instanceof FormSubmitError) { - return { message: cause.message, fields: cause.fields }; + return { message: cause.banner, fields: cause.fields }; } if (cause instanceof Error) { return { message: cause.message }; diff --git a/packages/mosaic/src/components/form/index.ts b/packages/mosaic/src/components/form/index.ts index 7205ca5d0ef..9728b9ad829 100644 --- a/packages/mosaic/src/components/form/index.ts +++ b/packages/mosaic/src/components/form/index.ts @@ -2,4 +2,11 @@ export type { AsyncFieldValidator, FieldConfig, FieldsConfig, FieldValidator } f export { FormSubmitError } from './form-submit-error'; export type { FieldFeedback, FieldFeedbackType, FormError, FormFieldErrors } from './form-submit-error'; export { useForm } from './use-form'; -export type { FormField, RegisteredField, TextFieldName, UseFormOptions, UseFormResult } from './use-form'; +export type { + ControlledField, + FormField, + RegisteredField, + TextFieldName, + UseFormOptions, + UseFormResult, +} from './use-form'; diff --git a/packages/mosaic/src/components/form/use-form.edit-password.test.ts b/packages/mosaic/src/components/form/use-form.edit-password.test.ts index 6afb91961c5..64f9c021936 100644 --- a/packages/mosaic/src/components/form/use-form.edit-password.test.ts +++ b/packages/mosaic/src/components/form/use-form.edit-password.test.ts @@ -107,8 +107,9 @@ describe('useForm: edit password', () => { it('shows the server rejection on the banner and under the field the model names', async () => { const onSubmit = vi.fn(() => Promise.reject( - new FormSubmitError('Password could not be changed.', { - currentPassword: 'Incorrect password.', + new FormSubmitError({ + message: 'Password could not be changed.', + fields: { currentPassword: 'Incorrect password.' }, }), ), ); diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts index 19771811cae..d174175ac38 100644 --- a/packages/mosaic/src/components/form/use-form.test.ts +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -45,6 +45,7 @@ describe('useForm', () => { expectTypeOf(result.current.setValue).parameter(0).toEqualTypeOf<'username' | 'age'>(); expectTypeOf(result.current.touch).parameter(0).toEqualTypeOf<'username' | 'age'>(); expectTypeOf(result.current.register).parameter(0).toEqualTypeOf<'username'>(); + expectTypeOf(result.current.control).parameter(0).toEqualTypeOf<'username' | 'age'>(); expectTypeOf(result.current.fields.age.feedback).toEqualTypeOf(); expectTypeOf(result.current.error).toEqualTypeOf(); expectTypeOf(result.current.reset).parameter(0).toEqualTypeOf<{ username: string; age: number } | undefined>(); @@ -85,7 +86,8 @@ describe('useForm', () => { const { result } = renderHook(() => useForm({ initialValues: { username: 'alex' }, - onSubmit: () => Promise.reject(new FormSubmitError('Could not save', { username: 'Taken' })), + onSubmit: () => + Promise.reject(new FormSubmitError({ message: 'Could not save', fields: { username: 'Taken' } })), }), ); await act(async () => { @@ -99,6 +101,21 @@ describe('useForm', () => { expect(result.current.fields.username.feedback).toBeUndefined(); }); + it('maps a fields-only FormSubmitError onto field feedback with no form message', async () => { + const failure = new FormSubmitError({ fields: { username: 'Taken', bio: 'Too long' } }); + expect(failure.message).toBe('Taken Too long'); + const { result } = renderHook(() => + useForm({ initialValues: { username: 'alex', bio: '' }, onSubmit: () => Promise.reject(failure) }), + ); + await act(async () => { + result.current.submit(); + await flush(); + }); + expect(result.current.error).toBeUndefined(); + expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Taken' }); + expect(result.current.fields.bio.feedback).toEqual({ type: 'error', message: 'Too long' }); + }); + it('shows only the message for a plain Error and a generic message otherwise', async () => { const plain = renderHook(() => useForm({ initialValues: { username: '' }, onSubmit: () => Promise.reject(new Error('Nope')) }), @@ -350,6 +367,18 @@ describe('useForm', () => { expect(result.current.fields.username.touched).toBe(true); }); + it('controls a value-shaped field of any type with its name, value, value and blur handlers', () => { + const { result } = renderHook(() => useForm({ initialValues: { code: '', count: 0 }, onSubmit: resolved })); + expect(result.current.control('count')).toMatchObject({ name: 'count', value: 0 }); + act(() => result.current.control('count').onValueChange(2)); + expect(result.current.values.count).toBe(2); + act(() => result.current.control('code').onValueChange('123456')); + expect(result.current.control('code').value).toBe('123456'); + expect(result.current.fields.code.touched).toBe(false); + act(() => result.current.control('code').onBlur()); + expect(result.current.fields.code.touched).toBe(true); + }); + it('focuses the first registered control with an error instead of submitting', () => { const onSubmit = vi.fn(resolved); const { result } = renderHook(() => diff --git a/packages/mosaic/src/components/form/use-form.ts b/packages/mosaic/src/components/form/use-form.ts index e3d9ea57a87..349177c1601 100644 --- a/packages/mosaic/src/components/form/use-form.ts +++ b/packages/mosaic/src/components/form/use-form.ts @@ -35,6 +35,14 @@ export interface RegisteredField void; } +export interface ControlledField { + name: K; + value: TValues[K]; + onValueChange: (value: TValues[K]) => void; + onBlur: () => void; + ref: (element: HTMLElement | null) => void; +} + export interface UseFormResult { id: string; values: TValues; @@ -44,6 +52,7 @@ export interface UseFormResult { isDirty: boolean; canSubmit: boolean; register: >(name: K) => RegisteredField; + control: (name: K) => ControlledField; setValue: (name: K, value: TValues[K]) => void; touch: (name: keyof TValues) => void; submit: () => void; @@ -127,6 +136,13 @@ export function useForm(options: UseFormOptions onBlur: () => touch(name), ref: refFor(name), }); + const control = (name: K): ControlledField => ({ + name, + value: values[name], + onValueChange: value => setValue(name, value), + onBlur: () => touch(name), + ref: refFor(name), + }); const isSubmitting = snapshot.value === 'submitting'; const initial = initialOf(context); @@ -150,6 +166,7 @@ export function useForm(options: UseFormOptions isDirty: keysOf(values).some(name => fields[name].isDirty), canSubmit: !isSubmitting && isSubmittable(context), register, + control, setValue, touch, submit, From 8b8f96a4d46b0bc8670995d1b48cb988e09fd5ed Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 13:57:19 -0400 Subject: [PATCH 05/11] feat(mosaic): queue submit during async validation and tolerate rejecting validators in useForm --- .../src/components/form/form.machine.ts | 68 +++++++++++------- .../src/components/form/use-form.test.ts | 69 +++++++++++++++++-- .../mosaic/src/components/form/use-form.ts | 11 +-- 3 files changed, 116 insertions(+), 32 deletions(-) diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index 1625a788ade..6cef30961ec 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -1,4 +1,5 @@ import { setup } from '../../machine/setup'; +import type { TransitionResult } from '../../machine/types'; import { keysOf, mapKeys } from '../../utils/object'; import type { FieldFeedback, FormError } from './form-submit-error'; import { FormSubmitError } from './form-submit-error'; @@ -40,6 +41,7 @@ export interface FormContext extends FormDeps { touched: Partial>; async: Partial>; error: FormError | undefined; + submitQueued: boolean; } export type FormEvent = @@ -72,12 +74,26 @@ export function firstInvalid(context: FormContext validatorFeedback(context, name)?.type === 'error'); } -export function isSubmittable(context: FormContext): boolean { - return ( - context.canSubmit(context.values) && - !keysOf(context.values).some(name => context.async[name]?.pending === true) && - firstInvalid(context) === undefined - ); +export function isValid(context: FormContext): boolean { + return context.canSubmit(context.values) && firstInvalid(context) === undefined; +} + +function isValidating(context: FormContext): boolean { + return keysOf(context.values).some(name => context.async[name]?.pending === true); +} + +function submitOrStay( + context: FormContext, + patch: Partial>, +): TransitionResult, FormState> { + const next = { ...context, ...patch }; + if (!isValid(next)) { + return { context: { ...patch, submitQueued: false } }; + } + if (isValidating(next)) { + return { context: { ...patch, submitQueued: true } }; + } + return { target: 'submitting', context: { ...patch, submitQueued: false, error: undefined } }; } function toFormError(cause: unknown, fallbackMessage: string): FormError { @@ -113,13 +129,23 @@ function asyncStateFor( return { value, feedback: undefined, pending: true }; } +type FormState = 'editing' | 'submitting'; + export function createFormMachine(deps: FormDeps) { const { createMachine, assign, fromPromise } = setup, FormEvent>(); return createMachine({ id: 'form', initial: 'editing', - context: { ...deps, values: deps.initialValues, baseline: undefined, touched: {}, async: {}, error: undefined }, + context: { + ...deps, + values: deps.initialValues, + baseline: undefined, + touched: {}, + async: {}, + error: undefined, + submitQueued: false, + }, states: { editing: { on: { @@ -128,26 +154,21 @@ export function createFormMachine(deps: FormDeps ({ context: { touched: { ...context.touched, [event.name]: true } } }), - VALIDATED: ({ context, event }) => - context.async[event.name]?.value === event.value - ? { - context: { - async: { - ...context.async, - [event.name]: { value: event.value, feedback: event.feedback, pending: false }, - }, - }, - } - : undefined, - SUBMIT: ({ context }) => { - const touched = mapKeys(context.values, (): true => true); - return isSubmittable(context) - ? { target: 'submitting', context: { touched, error: undefined } } - : { context: { touched } }; + VALIDATED: ({ context, event }) => { + if (context.async[event.name]?.value !== event.value) { + return undefined; + } + const async = { + ...context.async, + [event.name]: { value: event.value, feedback: event.feedback, pending: false }, + }; + return context.submitQueued ? submitOrStay(context, { async }) : { context: { async } }; }, + SUBMIT: ({ context }) => submitOrStay(context, { touched: mapKeys(context.values, (): true => true) }), RESET: ({ context, event }) => ({ context: { values: event.values ?? context.initialValues, @@ -155,6 +176,7 @@ export function createFormMachine(deps: FormDeps { checks.set(value, check); return check.promise; }); - const onSubmit = vi.fn(resolved); const { result } = renderHook(() => - useForm({ initialValues: { password: '' }, fields: { password: { validateAsync } }, onSubmit }), + useForm({ initialValues: { password: '' }, fields: { password: { validateAsync } }, onSubmit: resolved }), ); expect(validateAsync).not.toHaveBeenCalled(); expect(result.current.fields.password.isValidating).toBe(false); @@ -255,9 +254,6 @@ describe('useForm', () => { act(() => result.current.setValue('password', 'ab')); expect(validateAsync).toHaveBeenCalledTimes(2); expect(result.current.fields.password.isValidating).toBe(true); - expect(result.current.canSubmit).toBe(false); - act(() => result.current.submit()); - expect(onSubmit).not.toHaveBeenCalled(); await act(async () => { checks.get('ab')?.resolve({ type: 'success', message: 'Strong' }); await flush(); @@ -272,6 +268,69 @@ describe('useForm', () => { expect(result.current.fields.password.feedback).toEqual({ type: 'success', message: 'Strong' }); }); + it('queues a submit while async validation is pending and runs it once the field validates', async () => { + const check = deferred(); + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ + initialValues: { password: '' }, + fields: { password: { validateAsync: () => check.promise } }, + onSubmit, + }), + ); + act(() => result.current.setValue('password', 'ab')); + expect(result.current.canSubmit).toBe(true); + act(() => result.current.submit()); + expect(onSubmit).not.toHaveBeenCalled(); + expect(result.current.isSubmitting).toBe(true); + expect(result.current.canSubmit).toBe(false); + await act(async () => { + check.resolve(undefined); + await flush(); + }); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ password: 'ab' }); + expect(result.current.isSubmitting).toBe(false); + }); + + it('drops a queued submit when the field changes or its validation fails', async () => { + const checks = new Map>>(); + const validateAsync = (value: string) => { + const check = deferred(); + checks.set(value, check); + return check.promise; + }; + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ initialValues: { password: '' }, fields: { password: { validateAsync } }, onSubmit }), + ); + act(() => result.current.setValue('password', 'a')); + act(() => result.current.submit()); + expect(result.current.isSubmitting).toBe(true); + act(() => result.current.setValue('password', 'ab')); + expect(result.current.isSubmitting).toBe(false); + act(() => result.current.submit()); + await act(async () => { + checks.get('ab')?.resolve({ type: 'error', message: 'Weak' }); + await flush(); + }); + expect(onSubmit).not.toHaveBeenCalled(); + expect(result.current.isSubmitting).toBe(false); + expect(result.current.fields.password.feedback).toEqual({ type: 'error', message: 'Weak' }); + }); + + it('treats a rejected async validator as no feedback', async () => { + const validateAsync = vi.fn(() => Promise.reject(new Error('Network'))); + const { result } = renderHook(() => + useForm({ initialValues: { password: '' }, fields: { password: { validateAsync } }, onSubmit: resolved }), + ); + act(() => result.current.setValue('password', 'ab')); + await act(flush); + expect(result.current.fields.password.isValidating).toBe(false); + expect(result.current.fields.password.feedback).toBeUndefined(); + expect(result.current.canSubmit).toBe(true); + }); + it('skips async validation when the value returns to its initial value', async () => { const validateAsync = vi.fn(() => Promise.resolve(undefined)); const { result } = renderHook(() => diff --git a/packages/mosaic/src/components/form/use-form.ts b/packages/mosaic/src/components/form/use-form.ts index 349177c1601..861351ac7f6 100644 --- a/packages/mosaic/src/components/form/use-form.ts +++ b/packages/mosaic/src/components/form/use-form.ts @@ -5,7 +5,7 @@ import type { StateMachine } from '../../machine/types'; import { useMachine } from '../../machine/useMachine'; import { keysOf, mapKeys } from '../../utils/object'; import type { FieldsConfig, FormContext, FormEvent } from './form.machine'; -import { createFormMachine, fieldFeedback, firstInvalid, initialOf, isSubmittable } from './form.machine'; +import { createFormMachine, fieldFeedback, firstInvalid, initialOf, isValid } from './form.machine'; import type { FieldFeedback } from './form-submit-error'; export interface UseFormOptions { @@ -93,7 +93,10 @@ export function useForm(options: UseFormOptions if (validateAsync === undefined || async[name]?.pending !== true || async[name].value !== value) { return; } - void validateAsync(value, next).then(feedback => send({ type: 'VALIDATED', name, value, feedback })); + void validateAsync(value, next).then( + feedback => send({ type: 'VALIDATED', name, value, feedback }), + () => send({ type: 'VALIDATED', name, value, feedback: undefined }), + ); }, [actor, send], ); @@ -144,7 +147,7 @@ export function useForm(options: UseFormOptions ref: refFor(name), }); - const isSubmitting = snapshot.value === 'submitting'; + const isSubmitting = snapshot.value === 'submitting' || context.submitQueued; const initial = initialOf(context); const fields = mapKeys(values, (name): FormField => { const feedback = fieldFeedback(context, name); @@ -164,7 +167,7 @@ export function useForm(options: UseFormOptions error: context.error?.message, isSubmitting, isDirty: keysOf(values).some(name => fields[name].isDirty), - canSubmit: !isSubmitting && isSubmittable(context), + canSubmit: !isSubmitting && isValid(context), register, control, setValue, From 0414c76b3d903726634426fcc852513ff1a743ef Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 15:23:40 -0400 Subject: [PATCH 06/11] fix(mosaic): keep the last async feedback visible while the next check runs in useForm --- .../src/components/form/form.machine.ts | 23 ++++++++-- .../src/components/form/use-form.test.ts | 45 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index 6cef30961ec..8f586709346 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -55,11 +55,26 @@ export function initialOf(context: FormContext) return context.baseline ?? context.initialValues; } +function syncFeedback( + context: FormContext, + name: keyof TValues, +): FieldFeedback | undefined { + return context.fields?.[name]?.validate?.(context.values[name], context.values); +} + +function settledAsyncFeedback( + context: FormContext, + name: keyof TValues, +): FieldFeedback | undefined { + const state = context.async[name]; + return state?.pending === true ? undefined : state?.feedback; +} + export function validatorFeedback( context: FormContext, name: keyof TValues, ): FieldFeedback | undefined { - return context.fields?.[name]?.validate?.(context.values[name], context.values) ?? context.async[name]?.feedback; + return syncFeedback(context, name) ?? context.async[name]?.feedback; } export function fieldFeedback( @@ -71,7 +86,9 @@ export function fieldFeedback( } export function firstInvalid(context: FormContext): keyof TValues | undefined { - return keysOf(context.values).find(name => validatorFeedback(context, name)?.type === 'error'); + return keysOf(context.values).find( + name => (syncFeedback(context, name) ?? settledAsyncFeedback(context, name))?.type === 'error', + ); } export function isValid(context: FormContext): boolean { @@ -126,7 +143,7 @@ function asyncStateFor( if (context.fields?.[name]?.validateAsync === undefined || value === initialOf(context)[name]) { return undefined; } - return { value, feedback: undefined, pending: true }; + return { value, feedback: context.async[name]?.feedback, pending: true }; } type FormState = 'editing' | 'submitting'; diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts index 738104ef2b7..65cb3e5dade 100644 --- a/packages/mosaic/src/components/form/use-form.test.ts +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -268,6 +268,51 @@ describe('useForm', () => { expect(result.current.fields.password.feedback).toEqual({ type: 'success', message: 'Strong' }); }); + it('keeps the last async feedback while the next check runs and lets a stale error queue a submit', async () => { + const checks = new Map>>(); + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ + initialValues: { username: '' }, + fields: { + username: { + validateAsync: (value: string) => { + const check = deferred(); + checks.set(value, check); + return check.promise; + }, + }, + }, + onSubmit, + }), + ); + act(() => result.current.setValue('username', 'ab')); + await act(async () => { + checks.get('ab')?.resolve({ type: 'success', message: 'Available' }); + await flush(); + }); + act(() => result.current.setValue('username', 'abc')); + expect(result.current.fields.username.isValidating).toBe(true); + expect(result.current.fields.username.feedback).toEqual({ type: 'success', message: 'Available' }); + await act(async () => { + checks.get('abc')?.resolve({ type: 'error', message: 'Taken' }); + await flush(); + }); + act(() => result.current.touch('username')); + expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Taken' }); + act(() => result.current.setValue('username', 'abcd')); + expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Taken' }); + expect(result.current.canSubmit).toBe(true); + act(() => result.current.submit()); + expect(result.current.isSubmitting).toBe(true); + await act(async () => { + checks.get('abcd')?.resolve(undefined); + await flush(); + }); + expect(onSubmit).toHaveBeenCalledWith({ username: 'abcd' }); + expect(result.current.fields.username.feedback).toBeUndefined(); + }); + it('queues a submit while async validation is pending and runs it once the field validates', async () => { const check = deferred(); const onSubmit = vi.fn(resolved); From 627bff893efeaa168066f023096ff94eeb4bc7a1 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 15:23:41 -0400 Subject: [PATCH 07/11] docs(swingset): add useForm page --- .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 4 + packages/swingset/src/stories/use-form.mdx | 158 ++++++++++++++++++ .../swingset/src/stories/use-form.stories.tsx | 139 +++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 packages/swingset/src/stories/use-form.mdx create mode 100644 packages/swingset/src/stories/use-form.stories.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 89807ba58c5..c8cb44fcad3 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -118,6 +118,7 @@ const docModules: Record> = { hooks: { // Headless hooks — alphabetical. 'use-data-table': dynamic(() => import('../stories/use-data-table.mdx')), + 'use-form': dynamic(() => import('../stories/use-form.mdx')), }, localization: { localization: dynamic(() => import('../stories/localization.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 3dd91747be2..a4b099d78bf 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -277,6 +277,7 @@ import { } from '../stories/tooltip.component.stories'; import { meta as tooltipMeta } from '../stories/tooltip.stories'; import { meta as useDataTableMeta } from '../stories/use-data-table.stories'; +import { Default as UseFormDefault, meta as useFormMeta } from '../stories/use-form.stories'; import { Combined as UserButtonCombined, meta as userButtonMeta, @@ -678,6 +679,8 @@ const scrollAreaModule: StoryModule = { const useDataTableModule: StoryModule = { meta: useDataTableMeta }; +const useFormModule: StoryModule = { meta: useFormMeta, Default: UseFormDefault }; + const localizationModule: StoryModule = { meta: localizationMeta, Overrides: LocalizationOverrides, @@ -952,6 +955,7 @@ export const registry: StoryModule[] = [ scrollAreaModule, // Hooks useDataTableModule, + useFormModule, // Localization localizationModule, ]; diff --git a/packages/swingset/src/stories/use-form.mdx b/packages/swingset/src/stories/use-form.mdx new file mode 100644 index 00000000000..170e3e24c57 --- /dev/null +++ b/packages/swingset/src/stories/use-form.mdx @@ -0,0 +1,158 @@ +import * as UseFormStories from './use-form.stories'; + +# useForm + +Form state for Mosaic controllers. Takes the initial values, an `onSubmit`, optional per-field validators and an optional submit gate, and returns typed values, per-field feedback, and the props each control needs. Every type is inferred from `initialValues`. + +## Example + +Try `clerk` for a taken username (the error shows once you leave the field), `error` for a server rejection, and press Enter while the availability check is still running. + + + +## Usage + +```tsx +import { useForm } from '@clerk/mosaic/components/form'; + +const form = useForm({ + initialValues: { username: 'alex', displayName: 'Alex' }, + fields: { + username: { + validate: value => (value.length < 3 ? { type: 'error', message: 'Use at least 3 characters.' } : undefined), + validateAsync: value => checkUsername(value), + }, + }, + onSubmit: values => saveProfile(values), +}); +``` + +Wire the form element with `id` and `handleSubmit`, a text control with `register`, and a control that reports its value directly with `control`: + +```tsx +
+ {form.error ? ( + + {form.error} + + ) : null} + + + Username + + + + + {feedback?.type === 'error' ? feedback.message : null} + {feedback?.type === 'success' ? feedback.message : null} + + + + + + + Save + + +``` + +`register` accepts only fields whose value is a string. `control` accepts any field and passes the value through unchanged. A checkbox reads and writes through `values` and `setValue`: + +```tsx + form.setValue('signOutOfOtherSessions', event.target.checked)} +/> +``` + +When the view also needs its own ref on a registered control, merge them: + +```tsx +const { ref, ...control } = form.register('username'); +const mergedRef = useMergeRefs([ref, initialFocusRef]); +; +``` + +A model reports a failed save by rejecting `onSubmit` with `FormSubmitError`. `message` lands on `form.error` and each `fields` entry on that field's feedback. Either part may be omitted: fields alone show only under the fields with no banner. A plain `Error` shows only its message, and anything else shows the localized generic message. + +```ts +throw new FormSubmitError({ + message: 'Your profile could not be saved.', + fields: { username: 'That username is reserved.' }, +}); +``` + +A dialog calls `reset()` when it closes, or `reset(values)` to start from values loaded after mount. Both clear touched state, feedback, and the error, and set the baseline `isDirty` compares against. + +### Behavior + +- Error feedback shows only once a field is touched, by blur or by any submit attempt, and then updates live. Success, warning and info feedback show immediately. An untouched error still blocks submit, and a submit attempt focuses the first registered control in error. +- A sync `validate` is a pure function of the current values, so cross-field checks need no extra wiring. Its result takes precedence over the async one for the same field. +- An async `validateAsync` runs when its own field changes. The latest result wins, stale results drop, and the field reports `isValidating` while pending. The previous result stays visible until the next one arrives, but only a settled error blocks submit. It never runs for the initial value. A validator that rejects counts as no feedback. +- A submit while a validator is pending is queued: `isSubmitting` turns on straight away and `onSubmit` runs once the last check resolves clean. Changing a field drops the queued submit. +- A submit error under a field clears when that field changes. The banner persists until the next submit. +- Changes and resets are ignored while `onSubmit` is running. + +## Options + +| Option | Type | Default | Description | +| --------------- | --------------------------------------- | ------------- | --------------------------------------------------------------------------- | +| `initialValues` | `TValues` | — (required) | Starting values. Every other type is inferred from this object. | +| `fields` | `FieldsConfig` | — | Per-field `validate` and `validateAsync`. | +| `onSubmit` | `(values: TValues) => Promise` | — (required) | Runs on a valid submit. Reject with `FormSubmitError` to report a failure. | +| `canSubmit` | `(values: TValues) => boolean` | always `true` | Extra submit gate over the values. Blocks silently, with no field feedback. | + +### `FieldConfig` + +| Option | Type | Default | Description | +| --------------- | -------------------------------------------------------- | ------- | ------------------------------------------------- | +| `validate` | `(value, values) => FieldFeedback \| undefined` | — | Sync check, evaluated on every render. | +| `validateAsync` | `(value, values) => Promise` | — | Async check, run when this field's value changes. | + +## Return + +| Value | Type | Description | +| -------------- | ---------------------------------- | ---------------------------------------------------------------------------------------- | +| `id` | `string` | Stable id for the `
` element, so a submit button can target it from outside. | +| `values` | `TValues` | Current values. | +| `fields` | `Record` | Per-field state, see below. | +| `error` | `string \| undefined` | Banner message from the last failed submit. | +| `isSubmitting` | `boolean` | `onSubmit` is running, or a submit is queued behind async validation. | +| `isDirty` | `boolean` | Any field differs from its initial value. | +| `canSubmit` | `boolean` | Not submitting, no field in error, and `canSubmit(values)` is `true`. | +| `register` | `(name) => RegisteredField` | `name`, `value`, `onChange`, `onBlur` and `ref` for a text control. | +| `control` | `(name) => ControlledField` | `name`, `value`, `onValueChange`, `onBlur` and `ref` for a control with `onValueChange`. | +| `setValue` | `(name, value) => void` | Set one value. | +| `touch` | `(name) => void` | Mark one field touched. | +| `submit` | `() => void` | Touch every field and submit if valid. | +| `handleSubmit` | `(event) => void` | `preventDefault` then `submit`, for ``. | +| `reset` | `(values?) => void` | Return to `initialValues`, or to `values`, clearing touched state, feedback and errors. | + +### `FormField` + +| Field | Type | Description | +| -------------- | ---------------------------- | -------------------------------------------------------------------- | +| `feedback` | `FieldFeedback \| undefined` | The message to show. Errors are withheld until the field is touched. | +| `isValidating` | `boolean` | An async check is pending. | +| `touched` | `boolean` | Blurred or submitted at least once since the last reset. | +| `isDirty` | `boolean` | Differs from its initial value. | + +## Types + +```ts +type FieldFeedbackType = 'error' | 'warning' | 'success' | 'info'; +interface FieldFeedback { + type: FieldFeedbackType; + message: string; +} + +class FormSubmitError extends Error { + constructor(init: { message?: string; fields?: Partial> }); +} + +type TextFieldName = keys of TValues whose value is a string; +``` diff --git a/packages/swingset/src/stories/use-form.stories.tsx b/packages/swingset/src/stories/use-form.stories.tsx new file mode 100644 index 00000000000..d9be33c4568 --- /dev/null +++ b/packages/swingset/src/stories/use-form.stories.tsx @@ -0,0 +1,139 @@ +import { Banner } from '@clerk/mosaic/components/banner'; +import { SubmitButton } from '@clerk/mosaic/components/button'; +import { Field } from '@clerk/mosaic/components/field'; +import type { FieldFeedback, TextFieldName, UseFormResult } from '@clerk/mosaic/components/form'; +import { FormSubmitError, useForm } from '@clerk/mosaic/components/form'; +import { InputGroup } from '@clerk/mosaic/components/input-group'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export const meta: StoryMeta = { + group: 'Hooks', + status: 'stable', + title: 'useForm', + source: 'packages/mosaic/src/components/form/use-form.ts', +}; + +const styles = stylex.create({ + form: { + display: 'grid', + gap: 16, + maxWidth: 384, + width: '100%', + }, + field: { + display: 'grid', + gap: 8, + }, + actions: { + display: 'flex', + justifyContent: 'flex-end', + }, +}); + +interface ProfileValues { + username: string; + displayName: string; +} + +const TAKEN = new Set(['clerk', 'admin']); + +function wait(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function checkUsername(value: string): Promise { + await wait(600); + return TAKEN.has(value) + ? { type: 'error', message: `@${value} is already taken.` } + : { type: 'success', message: `@${value} is available.` }; +} + +async function saveProfile(values: ProfileValues): Promise { + await wait(800); + if (values.username === 'error') { + throw new FormSubmitError({ + message: 'Your profile could not be saved.', + fields: { username: 'That username is reserved.' }, + }); + } +} + +function TextField({ + form, + name, + label, +}: { + form: UseFormResult; + name: TextFieldName; + label: string; +}) { + const { feedback } = form.fields[name]; + return ( + + {label} + + + + + {feedback?.type === 'error' ? feedback.message : null} + {feedback?.type === 'success' ? feedback.message : null} + + + ); +} + +export function Default() { + const form = useForm({ + initialValues: { username: 'alex', displayName: 'Alex' }, + fields: { + username: { + validate: value => (value.length < 3 ? { type: 'error', message: 'Use at least 3 characters.' } : undefined), + validateAsync: checkUsername, + }, + displayName: { + validate: value => (value.trim() === '' ? { type: 'error', message: 'Enter a display name.' } : undefined), + }, + }, + onSubmit: saveProfile, + }); + + return ( + + {form.error ? ( + + {form.error} + + ) : null} + + +
+ + Save + +
+ + ); +} From 0bcc73881cb622fb8bffead8582737510413c856 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 16:01:02 -0400 Subject: [PATCH 08/11] fix(mosaic): surface every submit failure and read current options in useForm --- .../src/components/form/form.machine.ts | 17 ++--- .../src/components/form/use-form.test.ts | 70 +++++++++++++++++++ .../mosaic/src/components/form/use-form.ts | 4 +- packages/swingset/src/stories/use-form.mdx | 6 +- 4 files changed, 85 insertions(+), 12 deletions(-) diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index 8f586709346..752964de31b 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -114,13 +114,14 @@ function submitOrStay( } function toFormError(cause: unknown, fallbackMessage: string): FormError { - if (cause instanceof FormSubmitError) { - return { message: cause.banner, fields: cause.fields }; - } - if (cause instanceof Error) { - return { message: cause.message }; - } - return { message: fallbackMessage }; + const error: FormError = + cause instanceof FormSubmitError + ? { message: cause.banner, fields: cause.fields } + : cause instanceof Error + ? { message: cause.message } + : {}; + const visible = (error.message ?? '') !== '' || keysOf(error.fields ?? {}).length > 0; + return visible ? error : { ...error, message: fallbackMessage }; } function withoutField( @@ -199,7 +200,7 @@ export function createFormMachine(deps: FormDeps ctx.onSubmit(ctx.values), { + invoke: fromPromise(async ctx => ctx.onSubmit(ctx.values), { onDone: 'editing', onError: { target: 'editing', diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts index 65cb3e5dade..e345a35b53b 100644 --- a/packages/mosaic/src/components/form/use-form.test.ts +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -143,6 +143,54 @@ describe('useForm', () => { expect(unknown.result.current.error).toBe('Something went wrong. Please try again.'); }); + it('falls back to the generic message when a submit error has nothing to show', async () => { + const empty = renderHook(() => + useForm({ initialValues: { username: '' }, onSubmit: () => Promise.reject(new Error()) }), + ); + await act(async () => { + empty.result.current.submit(); + await flush(); + }); + expect(empty.result.current.error).toBe('Something went wrong. Please try again.'); + + const blank = renderHook(() => + useForm({ initialValues: { username: '' }, onSubmit: () => Promise.reject(new FormSubmitError({})) }), + ); + await act(async () => { + blank.result.current.submit(); + await flush(); + }); + expect(blank.result.current.error).toBe('Something went wrong. Please try again.'); + }); + + it('recovers from an onSubmit that throws synchronously', async () => { + const { result } = renderHook(() => + useForm({ + initialValues: { username: '' }, + onSubmit: () => { + throw new Error('Nope'); + }, + }), + ); + await act(async () => { + result.current.submit(); + await flush(); + }); + expect(result.current.isSubmitting).toBe(false); + expect(result.current.error).toBe('Nope'); + }); + + it('reads canSubmit and validators from the current render', () => { + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + useForm({ initialValues: { username: '' }, onSubmit: resolved, canSubmit: () => enabled }), + { initialProps: { enabled: true } }, + ); + expect(result.current.canSubmit).toBe(true); + rerender({ enabled: false }); + expect(result.current.canSubmit).toBe(false); + }); + it('clears the message on the next submit', async () => { let fail = true; const { result } = renderHook(() => @@ -376,6 +424,28 @@ describe('useForm', () => { expect(result.current.canSubmit).toBe(true); }); + it('treats an async validator that throws synchronously as no feedback', async () => { + const { result } = renderHook(() => + useForm({ + initialValues: { password: '' }, + fields: { + password: { + validateAsync: () => { + throw new Error('Nope'); + }, + }, + }, + onSubmit: resolved, + }), + ); + await act(async () => { + result.current.setValue('password', 'a'); + await flush(); + }); + expect(result.current.fields.password.isValidating).toBe(false); + expect(result.current.fields.password.feedback).toBeUndefined(); + }); + it('skips async validation when the value returns to its initial value', async () => { const validateAsync = vi.fn(() => Promise.resolve(undefined)); const { result } = renderHook(() => diff --git a/packages/mosaic/src/components/form/use-form.ts b/packages/mosaic/src/components/form/use-form.ts index 861351ac7f6..d4503b9adf9 100644 --- a/packages/mosaic/src/components/form/use-form.ts +++ b/packages/mosaic/src/components/form/use-form.ts @@ -82,7 +82,7 @@ export function useForm(options: UseFormOptions machineRef.current = createFormMachine(deps); } const [snapshot, send, actor] = useMachine(machineRef.current, { context: deps }); - const { context } = snapshot; + const context = { ...snapshot.context, ...deps }; const { values } = context; const setValue = useCallback( @@ -93,7 +93,7 @@ export function useForm(options: UseFormOptions if (validateAsync === undefined || async[name]?.pending !== true || async[name].value !== value) { return; } - void validateAsync(value, next).then( + void new Promise(resolve => resolve(validateAsync(value, next))).then( feedback => send({ type: 'VALIDATED', name, value, feedback }), () => send({ type: 'VALIDATED', name, value, feedback: undefined }), ); diff --git a/packages/swingset/src/stories/use-form.mdx b/packages/swingset/src/stories/use-form.mdx index 170e3e24c57..d1d94e73f35 100644 --- a/packages/swingset/src/stories/use-form.mdx +++ b/packages/swingset/src/stories/use-form.mdx @@ -19,7 +19,7 @@ Try `clerk` for a taken username (the error shows once you leave the field), `er import { useForm } from '@clerk/mosaic/components/form'; const form = useForm({ - initialValues: { username: 'alex', displayName: 'Alex' }, + initialValues: { username: 'alex', displayName: 'Alex', phoneNumber: '' }, fields: { username: { validate: value => (value.length < 3 ? { type: 'error', message: 'Use at least 3 characters.' } : undefined), @@ -28,6 +28,8 @@ const form = useForm({ }, onSubmit: values => saveProfile(values), }); + +const { feedback } = form.fields.username; ``` Wire the form element with `id` and `handleSubmit`, a text control with `register`, and a control that reports its value directly with `control`: @@ -40,7 +42,7 @@ Wire the form element with `id` and `handleSubmit`, a text control with `registe ) : null} - + Username From b645d3c377ed86e7674910841e0912eca55f6dad Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 16:01:45 -0400 Subject: [PATCH 09/11] docs(swingset): note throwing validators on the useForm page --- packages/swingset/src/stories/use-form.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swingset/src/stories/use-form.mdx b/packages/swingset/src/stories/use-form.mdx index d1d94e73f35..835ff2ad399 100644 --- a/packages/swingset/src/stories/use-form.mdx +++ b/packages/swingset/src/stories/use-form.mdx @@ -94,7 +94,7 @@ A dialog calls `reset()` when it closes, or `reset(values)` to start from values - Error feedback shows only once a field is touched, by blur or by any submit attempt, and then updates live. Success, warning and info feedback show immediately. An untouched error still blocks submit, and a submit attempt focuses the first registered control in error. - A sync `validate` is a pure function of the current values, so cross-field checks need no extra wiring. Its result takes precedence over the async one for the same field. -- An async `validateAsync` runs when its own field changes. The latest result wins, stale results drop, and the field reports `isValidating` while pending. The previous result stays visible until the next one arrives, but only a settled error blocks submit. It never runs for the initial value. A validator that rejects counts as no feedback. +- An async `validateAsync` runs when its own field changes. The latest result wins, stale results drop, and the field reports `isValidating` while pending. The previous result stays visible until the next one arrives, but only a settled error blocks submit. It never runs for the initial value. A validator that rejects or throws counts as no feedback. - A submit while a validator is pending is queued: `isSubmitting` turns on straight away and `onSubmit` runs once the last check resolves clean. Changing a field drops the queued submit. - A submit error under a field clears when that field changes. The banner persists until the next submit. - Changes and resets are ignored while `onSubmit` is running. From 6ed78dc02cbb7cd9c46604d99b3d320e00387a6a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 17:31:07 -0400 Subject: [PATCH 10/11] fix(mosaic): only count displayable field errors when deciding the useForm fallback message --- packages/mosaic/src/components/form/form.machine.ts | 9 +++++---- packages/mosaic/src/components/form/use-form.test.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index 752964de31b..af4eb9606d7 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -113,15 +113,16 @@ function submitOrStay( return { target: 'submitting', context: { ...patch, submitQueued: false, error: undefined } }; } -function toFormError(cause: unknown, fallbackMessage: string): FormError { +function toFormError(cause: unknown, context: FormContext): FormError { const error: FormError = cause instanceof FormSubmitError ? { message: cause.banner, fields: cause.fields } : cause instanceof Error ? { message: cause.message } : {}; - const visible = (error.message ?? '') !== '' || keysOf(error.fields ?? {}).length > 0; - return visible ? error : { ...error, message: fallbackMessage }; + const visible = + (error.message ?? '') !== '' || keysOf(context.values).some(name => (error.fields?.[name] ?? '') !== ''); + return visible ? error : { ...error, message: context.fallbackMessage }; } function withoutField( @@ -204,7 +205,7 @@ export function createFormMachine(deps: FormDeps ({ error: toFormError(e.error, ctx.fallbackMessage) })), + actions: assign((ctx, e) => ({ error: toFormError(e.error, ctx) })), }, }), }, diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts index e345a35b53b..7ba023787b2 100644 --- a/packages/mosaic/src/components/form/use-form.test.ts +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -161,6 +161,18 @@ describe('useForm', () => { await flush(); }); expect(blank.result.current.error).toBe('Something went wrong. Please try again.'); + + const unknownField = renderHook(() => + useForm({ + initialValues: { username: '' }, + onSubmit: () => Promise.reject(new FormSubmitError({ fields: { server: 'Nope' } })), + }), + ); + await act(async () => { + unknownField.result.current.submit(); + await flush(); + }); + expect(unknownField.result.current.error).toBe('Something went wrong. Please try again.'); }); it('recovers from an onSubmit that throws synchronously', async () => { From c4757c93d50a48dc708f0d41ff1d1818384d4035 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 17:37:35 -0400 Subject: [PATCH 11/11] fix(mosaic): focus the rejected control after a queued submit and drop undisplayable field errors in useForm --- .../src/components/form/form.machine.ts | 24 ++++++++++--- .../src/components/form/use-form.test.ts | 35 ++++++++++++++++--- .../mosaic/src/components/form/use-form.ts | 27 +++++++++----- packages/swingset/src/stories/use-form.mdx | 18 +++++----- 4 files changed, 78 insertions(+), 26 deletions(-) diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index af4eb9606d7..6cca449f303 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -1,7 +1,7 @@ import { setup } from '../../machine/setup'; import type { TransitionResult } from '../../machine/types'; import { keysOf, mapKeys } from '../../utils/object'; -import type { FieldFeedback, FormError } from './form-submit-error'; +import type { FieldFeedback, FormError, FormFieldErrors } from './form-submit-error'; import { FormSubmitError } from './form-submit-error'; export type FieldValidator = ( @@ -113,15 +113,31 @@ function submitOrStay( return { target: 'submitting', context: { ...patch, submitQueued: false, error: undefined } }; } +function displayableFields( + context: FormContext, + fields: FormFieldErrors | undefined, +): FormFieldErrors | undefined { + if (fields === undefined) { + return undefined; + } + const result: FormFieldErrors = {}; + for (const name of keysOf(context.values)) { + const message = fields[name]; + if (message !== undefined && message !== '') { + result[name] = message; + } + } + return result; +} + function toFormError(cause: unknown, context: FormContext): FormError { const error: FormError = cause instanceof FormSubmitError - ? { message: cause.banner, fields: cause.fields } + ? { message: cause.banner, fields: displayableFields(context, cause.fields) } : cause instanceof Error ? { message: cause.message } : {}; - const visible = - (error.message ?? '') !== '' || keysOf(context.values).some(name => (error.fields?.[name] ?? '') !== ''); + const visible = (error.message ?? '') !== '' || keysOf(error.fields ?? {}).length > 0; return visible ? error : { ...error, message: context.fallbackMessage }; } diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts index 7ba023787b2..0c7a11fbcf0 100644 --- a/packages/mosaic/src/components/form/use-form.test.ts +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -162,17 +162,18 @@ describe('useForm', () => { }); expect(blank.result.current.error).toBe('Something went wrong. Please try again.'); - const unknownField = renderHook(() => + const undisplayable = renderHook(() => useForm({ initialValues: { username: '' }, - onSubmit: () => Promise.reject(new FormSubmitError({ fields: { server: 'Nope' } })), + onSubmit: () => Promise.reject(new FormSubmitError({ fields: { username: '', server: 'Nope' } })), }), ); await act(async () => { - unknownField.result.current.submit(); + undisplayable.result.current.submit(); await flush(); }); - expect(unknownField.result.current.error).toBe('Something went wrong. Please try again.'); + expect(undisplayable.result.current.error).toBe('Something went wrong. Please try again.'); + expect(undisplayable.result.current.fields.username.feedback).toBeUndefined(); }); it('recovers from an onSubmit that throws synchronously', async () => { @@ -398,6 +399,32 @@ describe('useForm', () => { expect(result.current.isSubmitting).toBe(false); }); + it('focuses the first registered control in error when a queued submit is rejected by its check', async () => { + const check = deferred(); + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ + initialValues: { username: '' }, + fields: { username: { validateAsync: () => check.promise } }, + onSubmit, + }), + ); + const input = document.body.appendChild(document.createElement('input')); + result.current.register('username').ref(input); + act(() => result.current.setValue('username', 'ab')); + act(() => result.current.submit()); + expect(result.current.isSubmitting).toBe(true); + await act(async () => { + check.resolve({ type: 'error', message: 'Taken' }); + await flush(); + }); + expect(onSubmit).not.toHaveBeenCalled(); + expect(result.current.isSubmitting).toBe(false); + expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Taken' }); + expect(document.activeElement).toBe(input); + input.remove(); + }); + it('drops a queued submit when the field changes or its validation fails', async () => { const checks = new Map>>(); const validateAsync = (value: string) => { diff --git a/packages/mosaic/src/components/form/use-form.ts b/packages/mosaic/src/components/form/use-form.ts index d4503b9adf9..f3cb0a7082c 100644 --- a/packages/mosaic/src/components/form/use-form.ts +++ b/packages/mosaic/src/components/form/use-form.ts @@ -85,6 +85,12 @@ export function useForm(options: UseFormOptions const context = { ...snapshot.context, ...deps }; const { values } = context; + const focusFirstInvalid = useCallback(() => { + const invalid = firstInvalid(actor.getSnapshot().context); + if (invalid !== undefined) { + elements.current.get(invalid)?.focus(); + } + }, [actor]); const setValue = useCallback( (name: K, value: TValues[K]) => { send({ type: 'CHANGE', name, value }); @@ -93,12 +99,18 @@ export function useForm(options: UseFormOptions if (validateAsync === undefined || async[name]?.pending !== true || async[name].value !== value) { return; } - void new Promise(resolve => resolve(validateAsync(value, next))).then( - feedback => send({ type: 'VALIDATED', name, value, feedback }), - () => send({ type: 'VALIDATED', name, value, feedback: undefined }), + const settle = (feedback: FieldFeedback | undefined) => { + const { submitQueued } = actor.getSnapshot().context; + send({ type: 'VALIDATED', name, value, feedback }); + if (submitQueued) { + focusFirstInvalid(); + } + }; + void new Promise(resolve => resolve(validateAsync(value, next))).then(settle, () => + settle(undefined), ); }, - [actor, send], + [actor, focusFirstInvalid, send], ); const touch = useCallback((name: keyof TValues) => send({ type: 'TOUCH', name }), [send]); const refFor = useCallback((name: keyof TValues): ElementRef => { @@ -118,11 +130,8 @@ export function useForm(options: UseFormOptions }, []); const submit = useCallback(() => { send({ type: 'SUBMIT' }); - const invalid = firstInvalid(actor.getSnapshot().context); - if (invalid !== undefined) { - elements.current.get(invalid)?.focus(); - } - }, [actor, send]); + focusFirstInvalid(); + }, [focusFirstInvalid, send]); const handleSubmit = useCallback( (event: { preventDefault: () => void }) => { event.preventDefault(); diff --git a/packages/swingset/src/stories/use-form.mdx b/packages/swingset/src/stories/use-form.mdx index 835ff2ad399..cb2126ce7c9 100644 --- a/packages/swingset/src/stories/use-form.mdx +++ b/packages/swingset/src/stories/use-form.mdx @@ -92,21 +92,21 @@ A dialog calls `reset()` when it closes, or `reset(values)` to start from values ### Behavior -- Error feedback shows only once a field is touched, by blur or by any submit attempt, and then updates live. Success, warning and info feedback show immediately. An untouched error still blocks submit, and a submit attempt focuses the first registered control in error. +- Error feedback shows only once a field is touched, by blur or by any submit attempt, and then updates live. Success, warning and info feedback show immediately. An untouched error still blocks submit, and a submit attempt focuses the first registered control in error, in `initialValues` key order. - A sync `validate` is a pure function of the current values, so cross-field checks need no extra wiring. Its result takes precedence over the async one for the same field. - An async `validateAsync` runs when its own field changes. The latest result wins, stale results drop, and the field reports `isValidating` while pending. The previous result stays visible until the next one arrives, but only a settled error blocks submit. It never runs for the initial value. A validator that rejects or throws counts as no feedback. -- A submit while a validator is pending is queued: `isSubmitting` turns on straight away and `onSubmit` runs once the last check resolves clean. Changing a field drops the queued submit. +- A submit while a validator is pending is queued: `isSubmitting` turns on straight away and `onSubmit` runs once the last check resolves clean. Changing a field drops the queued submit. A check that fails drops it and focuses that control. - A submit error under a field clears when that field changes. The banner persists until the next submit. - Changes and resets are ignored while `onSubmit` is running. ## Options -| Option | Type | Default | Description | -| --------------- | --------------------------------------- | ------------- | --------------------------------------------------------------------------- | -| `initialValues` | `TValues` | — (required) | Starting values. Every other type is inferred from this object. | -| `fields` | `FieldsConfig` | — | Per-field `validate` and `validateAsync`. | -| `onSubmit` | `(values: TValues) => Promise` | — (required) | Runs on a valid submit. Reject with `FormSubmitError` to report a failure. | -| `canSubmit` | `(values: TValues) => boolean` | always `true` | Extra submit gate over the values. Blocks silently, with no field feedback. | +| Option | Type | Default | Description | +| --------------- | --------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `initialValues` | `TValues` | — (required) | Starting values. Every other type is inferred from this object. Every field must be present, even when its value is `undefined`. | +| `fields` | `FieldsConfig` | — | Per-field `validate` and `validateAsync`. | +| `onSubmit` | `(values: TValues) => Promise` | — (required) | Runs on a valid submit. Reject with `FormSubmitError` to report a failure. | +| `canSubmit` | `(values: TValues) => boolean` | always `true` | Extra submit gate over the values. Blocks silently, with no field feedback. | ### `FieldConfig` @@ -124,7 +124,7 @@ A dialog calls `reset()` when it closes, or `reset(values)` to start from values | `fields` | `Record` | Per-field state, see below. | | `error` | `string \| undefined` | Banner message from the last failed submit. | | `isSubmitting` | `boolean` | `onSubmit` is running, or a submit is queued behind async validation. | -| `isDirty` | `boolean` | Any field differs from its initial value. | +| `isDirty` | `boolean` | Any field differs from its initial value, compared with `Object.is`. | | `canSubmit` | `boolean` | Not submitting, no field in error, and `canSubmit(values)` is `true`. | | `register` | `(name) => RegisteredField` | `name`, `value`, `onChange`, `onBlur` and `ref` for a text control. | | `control` | `(name) => ControlledField` | `name`, `value`, `onValueChange`, `onBlur` and `ref` for a control with `onValueChange`. |