Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/react-form-blur-lifecycle.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 47 additions & 1 deletion libs/react-form/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
```
Expand Down Expand Up @@ -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 });
```
35 changes: 35 additions & 0 deletions libs/react-form/src/blur-submission.test-d.tsx
Original file line number Diff line number Diff line change
@@ -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;
});
227 changes: 227 additions & 0 deletions libs/react-form/src/blur-submission.test.tsx
Original file line number Diff line number Diff line change
@@ -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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(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<void>();
const invalid = vi.fn(() => pending.promise);
const handler = hook.result.current.form.handleSubmit(vi.fn(), {
onInvalid: invalid
});
let first!: Promise<void>;
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<void>;
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();
}
);
});
5 changes: 4 additions & 1 deletion libs/react-form/src/contexts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ export type FormContextValue = {
schema: ObjectSchemaBuilder<any, any, any>;
options: UseSchemaFormOptions;
pathMap: Map<PropertyDescriptorInner<any, any, any>, string>;
triggerValidation?: (markTouched: boolean) => Promise<unknown>;
triggerValidation?: (
markTouched: boolean,
immediate?: boolean
) => Promise<unknown>;
};

/**
Expand Down
Loading
Loading