diff --git a/.changeset/react-form-blur-lifecycle.md b/.changeset/react-form-blur-lifecycle.md new file mode 100644 index 00000000..e0b15f2b --- /dev/null +++ b/.changeset/react-form-blur-lifecycle.md @@ -0,0 +1,8 @@ +--- +"@cleverbrush/react-form": minor +--- + +Add opt-in immediate blur validation that touches only the blurred field, and an +awaitable invalid-submission callback with existing duplicate and stale-result +protection. Opt-in validation-rule refresh retains edited values and field state +while adopting current bounds/messages and suppressing stale callbacks. Existing blur and submission defaults remain unchanged. diff --git a/libs/react-form/README.md b/libs/react-form/README.md index f0443fea..a0824c7e 100644 --- a/libs/react-form/README.md +++ b/libs/react-form/README.md @@ -430,7 +430,8 @@ Creates a form instance bound to a schema. Returns field binding and form lifecy ```tsx const form = useSchemaForm(UserSchema, { createMissingStructure: true, // default: true — auto-create parent objects when setting nested values - validateOnMount: false, // default: false — set to true to show errors immediately on mount + validateOnMount: false, // default: false — set to true to show errors immediately + validateOnBlur: true, // opt in; touches only the blurred field on mount validationDebounceMs: 300 // optional — debounce onChange validation (ms); validate()/submit() are always immediate }); ``` @@ -760,3 +761,48 @@ function App() { ## License BSD-3-Clause + +## Blur validation and rejected inline edits + +Set `validateOnBlur: true` to validate immediately when a field blurs, even when +change validation is debounced. Only the blurred field becomes touched; errors on +other fields remain available without forcing their messages to appear. The +option defaults to false, preserving touch-only blur behavior for existing forms. +Explicit `validate()` and submissions continue to touch all fields. + +Managed submission accepts an optional `onInvalid(result)` callback. It runs only +for current invalid validation and is awaited under the same duplicate-submission +lock as a successful submission. Values changed during validation, reset and +unmount suppress stale callbacks. Exceptions from `onInvalid` propagate and are +not translated by `onError`, because no write was attempted. + +```tsx +const form = useSchemaForm(QuantitySchema, { validateOnBlur: true }); +const quantity = form.useField(t => t.quantity); +const commit = form.handleSubmit(saveQuantity, { + onInvalid: () => form.reset(savedQuantity) +}); +// Application policy: an inline editor can restore its saved baseline on blur. +// Explicit form submission can use a handler without onInvalid to keep errors. +``` + +The callback receives the native failed `ValidationResult`; custom validators +can leave `errors` undefined. No UI or reset policy is chosen by the package. + +## Refreshing validation rules + +Schemas are captured once by default. Set `refreshValidationRules: true` when +bounds or translated messages change while the field names and value types stay +fixed. Memoize the schema by those rule dependencies; do not construct a new +schema on every form-state render. A rule update immediately revalidates without +changing values, initial baselines, dirty state or which fields are touched. Older +validation and managed submission callbacks are ignored. Field renderers keep +the original binding schema; mount a new form when the field structure or types +change, rather than using this option for a different form model. + +```tsx +const schema = useMemo(() => object({ + quantity: number().min(minimum, minimumMessage) +}), [minimum, minimumMessage]); +const form = useSchemaForm(schema, { refreshValidationRules: true }); +``` diff --git a/libs/react-form/src/blur-submission.test-d.tsx b/libs/react-form/src/blur-submission.test-d.tsx new file mode 100644 index 00000000..28f4ac6b --- /dev/null +++ b/libs/react-form/src/blur-submission.test-d.tsx @@ -0,0 +1,35 @@ +import { object, string, type ValidationResult } from '@cleverbrush/schema'; +import { expectTypeOf, test } from 'vitest'; +import { useSchemaForm } from './hooks.js'; +import type { FormSubmitOptions, UseSchemaFormOptions } from './types.js'; + +const schema = object({ name: string() }); +test('blur and invalid-submit options preserve public value types', () => { + const options: UseSchemaFormOptions = { + validateOnBlur: true, + refreshValidationRules: true + }; + const form = useSchemaForm(schema, options); + form.handleSubmit( + values => { + expectTypeOf(values).toEqualTypeOf<{ name: string }>(); + }, + { + onInvalid: async result => { + expectTypeOf(result).toEqualTypeOf< + ValidationResult<{ name: string }> + >(); + } + } + ); + const invalidOptions: UseSchemaFormOptions = { + // @ts-expect-error Blur validation is an opt-in boolean. + validateOnBlur: 'always' + }; + const wrongCallback: FormSubmitOptions<{ name: string }> = { + // @ts-expect-error Invalid validation still carries the schema's value type. + onInvalid: (_result: ValidationResult<{ count: number }>) => {} + }; + void invalidOptions; + void wrongCallback; +}); diff --git a/libs/react-form/src/blur-submission.test.tsx b/libs/react-form/src/blur-submission.test.tsx new file mode 100644 index 00000000..2e785cb2 --- /dev/null +++ b/libs/react-form/src/blur-submission.test.tsx @@ -0,0 +1,227 @@ +import { object, string } from '@cleverbrush/schema'; +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { useSchemaForm } from './hooks.js'; + +const schema = object({ + first: string().minLength(1, 'First required'), + second: string().minLength(1, 'Second required') +}); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { + resolve = done; + }); + return { promise, resolve }; +} +function bound(options = {}) { + return renderHook(() => { + const form = useSchemaForm(schema, options); + return { + form, + first: form.useField(t => t.first), + second: form.useField(t => t.second) + }; + }); +} +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); +describe('opt-in immediate blur validation', () => { + test('keeps touch-only behavior by default', () => { + const validate = vi.spyOn(schema, 'validateAsync'); + const hook = bound(); + act(() => hook.result.current.form.reset({ first: '', second: '' })); + act(() => hook.result.current.first.onBlur()); + expect(hook.result.current.first.touched).toBe(true); + expect(hook.result.current.second.touched).toBe(false); + expect(validate).not.toHaveBeenCalled(); + }); + test('validates untouched empty input and touches only the blurred field', async () => { + const hook = bound({ validateOnBlur: true }); + act(() => hook.result.current.form.reset({ first: '', second: '' })); + act(() => hook.result.current.first.onBlur()); + await waitFor(() => + expect(hook.result.current.first.error).toBe('First required') + ); + expect(hook.result.current.first.touched).toBe(true); + expect(hook.result.current.second.error).toBe('Second required'); + expect(hook.result.current.second.touched).toBe(false); + act(() => hook.result.current.first.onChange('ok')); + await waitFor(() => + expect(hook.result.current.first.error).toBeUndefined() + ); + }); + test('flushes debounce once without a second scheduled validation', async () => { + vi.useFakeTimers(); + const validate = vi.spyOn(schema, 'validateAsync'); + const hook = bound({ validateOnBlur: true, validationDebounceMs: 300 }); + act(() => + hook.result.current.form.reset({ first: 'ok', second: 'ok' }) + ); + act(() => hook.result.current.first.onChange('')); + expect(validate).not.toHaveBeenCalled(); + await act(async () => { + hook.result.current.first.onBlur(); + }); + expect(validate).toHaveBeenCalledTimes(1); + expect(hook.result.current.first.error).toBe('First required'); + await act(() => vi.advanceTimersByTimeAsync(500)); + expect(validate).toHaveBeenCalledTimes(1); + expect(hook.result.current.second.touched).toBe(false); + }); + test.each(['change', 'reset', 'unmount'] as const)( + 'ignores stale blur errors after %s', + async change => { + const pending = deferred<{ + valid: false; + errors: { message: string }[]; + }>(); + const guarded = object({ + name: string().addValidator(() => pending.promise) + }); + const hook = renderHook(() => { + const form = useSchemaForm(guarded, { + validateOnBlur: true, + validationDebounceMs: 300 + }); + return { form, name: form.useField(t => t.name) }; + }); + act(() => hook.result.current.form.reset({ name: 'old' })); + act(() => hook.result.current.name.onBlur()); + if (change === 'change') + act(() => hook.result.current.form.setValue({ name: 'new' })); + else if (change === 'reset') + act(() => hook.result.current.form.reset({ name: 'new' })); + else hook.unmount(); + await act(async () => { + pending.resolve({ + valid: false, + errors: [{ message: 'Old error' }] + }); + }); + if (change !== 'unmount') { + expect(hook.result.current.name.value).toBe('new'); + expect(hook.result.current.name.error).toBeUndefined(); + expect(hook.result.current.name.validating).toBe(false); + } + } + ); +}); +describe('managed invalid submissions', () => { + test('passes failure to application policy without calling the writer', async () => { + const hook = bound(); + act(() => + hook.result.current.form.reset({ first: '', second: 'saved' }) + ); + const write = vi.fn(); + const invalid = vi.fn(result => { + expect(result.valid).toBe(false); + hook.result.current.form.reset({ first: 'saved', second: 'saved' }); + }); + await act(() => + hook.result.current.form.handleSubmit(write, { + onInvalid: invalid + })() + ); + expect(invalid).toHaveBeenCalledOnce(); + expect(write).not.toHaveBeenCalled(); + expect(hook.result.current.form.getValue()).toEqual({ + first: 'saved', + second: 'saved' + }); + expect(hook.result.current.first.touched).toBe(false); + expect(hook.result.current.form.submitting).toBe(false); + }); + test('holds duplicate protection until an asynchronous invalid callback settles', async () => { + const hook = bound(); + act(() => hook.result.current.form.reset({ first: '', second: '' })); + const pending = deferred(); + const invalid = vi.fn(() => pending.promise); + const handler = hook.result.current.form.handleSubmit(vi.fn(), { + onInvalid: invalid + }); + let first!: Promise; + await act(async () => { + first = handler(); + await Promise.resolve(); + }); + await waitFor(() => expect(invalid).toHaveBeenCalledOnce()); + await act(() => handler()); + expect(invalid).toHaveBeenCalledOnce(); + expect(hook.result.current.form.submitting).toBe(true); + await act(async () => { + pending.resolve(); + await first; + }); + expect(hook.result.current.form.submitting).toBe(false); + }); + test('propagates invalid-callback exceptions and releases the lock for retry', async () => { + const hook = bound(); + act(() => hook.result.current.form.reset({ first: '', second: '' })); + const error = new Error('application callback'); + const onError = vi.fn(); + await act(async () => { + await expect( + hook.result.current.form.handleSubmit(vi.fn(), { + onInvalid: () => { + throw error; + }, + onError + })() + ).rejects.toBe(error); + }); + expect(onError).not.toHaveBeenCalled(); + expect(hook.result.current.form.submitting).toBe(false); + act(() => + hook.result.current.form.setValue({ first: 'ok', second: 'ok' }) + ); + const write = vi.fn(); + const invalid = vi.fn(); + await act(() => + hook.result.current.form.handleSubmit(write, { + onInvalid: invalid + })() + ); + expect(write).toHaveBeenCalledOnce(); + expect(invalid).not.toHaveBeenCalled(); + }); + test.each(['change', 'reset', 'unmount'] as const)( + 'suppresses invalid callbacks after %s during validation', + async change => { + const pending = deferred<{ + valid: false; + errors: { message: string }[]; + }>(); + const guarded = object({ + name: string().addValidator(() => pending.promise) + }); + const hook = renderHook(() => useSchemaForm(guarded)); + act(() => hook.result.current.reset({ name: 'old' })); + const write = vi.fn(); + const invalid = vi.fn(); + let submission!: Promise; + act(() => { + submission = hook.result.current.handleSubmit(write, { + onInvalid: invalid + })(); + }); + if (change === 'change') + act(() => hook.result.current.setValue({ name: 'new' })); + else if (change === 'reset') + act(() => hook.result.current.reset({ name: 'new' })); + else hook.unmount(); + await act(async () => { + pending.resolve({ + valid: false, + errors: [{ message: 'Old error' }] + }); + await submission; + }); + expect(invalid).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + } + ); +}); diff --git a/libs/react-form/src/contexts.ts b/libs/react-form/src/contexts.ts index ddea0016..6d903ce4 100644 --- a/libs/react-form/src/contexts.ts +++ b/libs/react-form/src/contexts.ts @@ -22,7 +22,10 @@ export type FormContextValue = { schema: ObjectSchemaBuilder; options: UseSchemaFormOptions; pathMap: Map, string>; - triggerValidation?: (markTouched: boolean) => Promise; + triggerValidation?: ( + markTouched: boolean, + immediate?: boolean + ) => Promise; }; /** diff --git a/libs/react-form/src/hooks.ts b/libs/react-form/src/hooks.ts index 350e61a5..3a7bacbf 100644 --- a/libs/react-form/src/hooks.ts +++ b/libs/react-form/src/hooks.ts @@ -13,6 +13,7 @@ import { import { useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useSyncExternalStore @@ -37,6 +38,9 @@ import type { UseSchemaFormOptions } from './types.js'; +const useBeforePaint = + typeof window === 'undefined' ? useEffect : useLayoutEffect; + /** A stable form controller with reactive field and submission subscriptions. */ export type SchemaFormInstance< TSchema extends ObjectSchemaBuilder @@ -200,6 +204,15 @@ export function useSchemaForm< [cancelScheduledValidation, store] ); + useBeforePaint(() => { + if (!options?.refreshValidationRules || schemaRef.current === schema) + return; + schemaRef.current = schema; + epochRef.current++; + validationGenRef.current++; + void runValidation(false); + }, [schema, options?.refreshValidationRules, runValidation]); + const validate = useCallback(() => runValidation(true), [runValidation]); const submit = useCallback(() => validate(), [validate]); const reset = useCallback( @@ -223,10 +236,10 @@ export function useSchemaForm< [store, cancelScheduledValidation] ); const triggerValidation = useCallback( - (markTouched: boolean) => { + (markTouched: boolean, immediate = false) => { cancelScheduledValidation(); const delay = optionsRef.current.validationDebounceMs; - if (delay != null && delay > 0) { + if (!immediate && delay != null && delay > 0) { timerRef.current = setTimeout(() => { timerRef.current = null; void runValidation(markTouched); @@ -260,13 +273,12 @@ export function useSchemaForm< try { const revision = store.getRevision(); const result = await runValidation(true); - if ( - !current() || - revision !== store.getRevision() || - !result.valid || - result.object === undefined - ) + if (!current() || revision !== store.getRevision()) return; + if (!result.valid) { + await submissionOptions?.onInvalid?.(result); return; + } + if (result.object === undefined) return; const values = result.object; let outcome: FormSubmitResult; try { @@ -404,10 +416,10 @@ export function useFieldFromContext( }, [store, path, options.createMissingStructure, triggerValidation] ); - const onBlur = useCallback( - () => store.updateFieldState(path, { touched: true }), - [store, path] - ); + const onBlur = useCallback(() => { + store.updateFieldState(path, { touched: true }); + if (options.validateOnBlur) void triggerValidation?.(false, true); + }, [store, path, options.validateOnBlur, triggerValidation]); return { ...fieldState, onChange, diff --git a/libs/react-form/src/rule-refresh.test.tsx b/libs/react-form/src/rule-refresh.test.tsx new file mode 100644 index 00000000..b885d00e --- /dev/null +++ b/libs/react-form/src/rule-refresh.test.tsx @@ -0,0 +1,102 @@ +import { object, string } from '@cleverbrush/schema'; +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { useMemo } from 'react'; +import { afterEach, expect, test, vi } from 'vitest'; +import { useSchemaForm } from './hooks.js'; + +afterEach(cleanup); +function rules(minimum: number) { + return object({ + name: string().minLength(minimum, `Minimum ${minimum}`), + note: string().optional() + }); +} +function useRules(minimum: number, enabled?: boolean) { + const schema = useMemo(() => rules(minimum), [minimum]); + const form = useSchemaForm(schema, { refreshValidationRules: enabled }); + return { + form, + name: form.useField(t => t.name), + note: form.useField(t => t.note) + }; +} +test('captures the first schema unless refresh is enabled', async () => { + const hook = renderHook(({ minimum }) => useRules(minimum), { + initialProps: { minimum: 1 } + }); + act(() => hook.result.current.form.reset({ name: 'ab' })); + hook.rerender({ minimum: 3 }); + await act(() => hook.result.current.form.validate()); + expect(hook.result.current.name.error).toBeUndefined(); +}); +test('adopts new bounds/messages without discarding values or field state', async () => { + const hook = renderHook(({ minimum }) => useRules(minimum, true), { + initialProps: { minimum: 1 } + }); + act(() => hook.result.current.form.reset({ name: 'saved' })); + act(() => { + hook.result.current.name.onChange('ab'); + hook.result.current.name.onBlur(); + }); + await waitFor(() => + expect(hook.result.current.name.validating).toBe(false) + ); + hook.rerender({ minimum: 3 }); + await waitFor(() => + expect(hook.result.current.name.error).toBe('Minimum 3') + ); + expect(hook.result.current.name).toMatchObject({ + value: 'ab', + initialValue: 'saved', + dirty: true, + touched: true + }); + expect(hook.result.current.note.touched).toBe(false); + hook.rerender({ minimum: 1 }); + await waitFor(() => expect(hook.result.current.name.error).toBeUndefined()); + expect(hook.result.current.name.dirty).toBe(true); +}); +test('discards obsolete managed validation after a rule change', async () => { + let resolve!: (value: { + valid: false; + errors: { message: string }[]; + }) => void; + const pending = new Promise<{ + valid: false; + errors: { message: string }[]; + }>(done => { + resolve = done; + }); + const first = object({ name: string().addValidator(() => pending) }); + const second = object({ name: string() }); + const hook = renderHook( + ({ schema }) => { + const form = useSchemaForm(schema, { + refreshValidationRules: true + }); + return { form, name: form.useField(t => t.name) }; + }, + { initialProps: { schema: first } } + ); + act(() => hook.result.current.form.reset({ name: 'current' })); + const write = vi.fn(), + invalid = vi.fn(); + let submitted!: Promise; + act(() => { + submitted = hook.result.current.form.handleSubmit(write, { + onInvalid: invalid + })(); + }); + hook.rerender({ schema: second }); + await waitFor(() => + expect(hook.result.current.name.validating).toBe(false) + ); + await act(async () => { + resolve({ valid: false, errors: [{ message: 'obsolete' }] }); + await submitted; + }); + expect(write).not.toHaveBeenCalled(); + expect(invalid).not.toHaveBeenCalled(); + expect(hook.result.current.name.error).toBeUndefined(); + expect(hook.result.current.name.value).toBe('current'); +}); diff --git a/libs/react-form/src/types.ts b/libs/react-form/src/types.ts index d72a6657..d82f707e 100644 --- a/libs/react-form/src/types.ts +++ b/libs/react-form/src/types.ts @@ -1,4 +1,4 @@ -import type { SchemaBuilder } from '@cleverbrush/schema'; +import type { SchemaBuilder, ValidationResult } from '@cleverbrush/schema'; import type { FormEvent, ReactNode } from 'react'; /** @@ -123,6 +123,21 @@ export type UseSchemaFormOptions = { * @default false */ validateOnMount?: boolean; + /** + * Validate immediately when a field blurs, cancelling pending debounced + * validation. Only that field becomes touched; other fields keep their state. + * Existing change-triggered validation is unaffected. + * @default false + */ + validateOnBlur?: boolean; + /** + * Adopt changed validation rules without resetting values, touched or dirty. + * Field names and value types must stay fixed for the form's lifetime. + * Changes invalidate older validation/submission callbacks and revalidate + * immediately without touching additional fields. + * @default false + */ + refreshValidationRules?: boolean; /** * Debounce delay in milliseconds for onChange-triggered validation. * When set, rapid field changes only trigger one validation run after @@ -164,6 +179,12 @@ export type FormSubmitResult = /** Application-owned success UI and exception translation. Rethrows propagate. */ export type FormSubmitOptions = { + /** + * Awaited for current invalid validation, before releasing the submission + * lock. Values changed during validation, reset and unmount suppress it. + * Exceptions propagate instead of being translated as failed writes. + */ + onInvalid?: (result: ValidationResult) => void | Promise; onSuccess?: ( data: TData | undefined, values: TValues