Skip to content

Commit 5f8ae7b

Browse files
authored
chore(form): revalidate on any change after error in field (#8)
* chore(form): revalidate on any change after error in field * fix(form): pr fixes * fix(form): pr fix
1 parent 277ed8a commit 5f8ae7b

2 files changed

Lines changed: 105 additions & 10 deletions

File tree

packages/form/src/index.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1363,3 +1363,81 @@ describe("createForm — re-render resilience", () => {
13631363
cleanup(el);
13641364
});
13651365
});
1366+
1367+
describe("createForm — error clearing on fix", () => {
1368+
it("clears errors on change after failed submission", () => {
1369+
const el = makeForm(basicHtml);
1370+
const form = createForm({ el, schema: basicSchema, onSubmit: () => {} });
1371+
const unmount = form.mount();
1372+
1373+
setField(el, "email", "bad");
1374+
submit(el);
1375+
expect(Object.keys(form.errors()).length).toBeGreaterThan(0);
1376+
1377+
const input = setField(el, "email", "ada@example.com");
1378+
setField(el, "name", "Ada");
1379+
dispatch(input, "change");
1380+
1381+
expect(form.errors()).toEqual({});
1382+
unmount();
1383+
cleanup(el);
1384+
});
1385+
1386+
it("clears errors on input keystroke after failed submission", () => {
1387+
const el = makeForm(basicHtml);
1388+
const form = createForm({ el, schema: basicSchema, onSubmit: () => {} });
1389+
const unmount = form.mount();
1390+
1391+
setField(el, "email", "bad");
1392+
submit(el);
1393+
expect(Object.keys(form.errors()).length).toBeGreaterThan(0);
1394+
1395+
const input = setField(el, "email", "ada@example.com");
1396+
setField(el, "name", "Ada");
1397+
dispatch(input, "input");
1398+
1399+
expect(form.errors()).toEqual({});
1400+
unmount();
1401+
cleanup(el);
1402+
});
1403+
1404+
it("does not revalidate on change when there are no errors (validateOn: submit)", () => {
1405+
const el = makeForm(basicHtml);
1406+
const form = createForm({
1407+
el,
1408+
schema: basicSchema,
1409+
onSubmit: () => {},
1410+
validateOn: "submit",
1411+
});
1412+
const unmount = form.mount();
1413+
1414+
// no prior submission — errors are empty
1415+
const input = setField(el, "email", "bad");
1416+
dispatch(input, "change");
1417+
1418+
// should stay empty — no proactive validation when clean
1419+
expect(form.errors()).toEqual({});
1420+
unmount();
1421+
cleanup(el);
1422+
});
1423+
1424+
it("does not revalidate on input when there are no errors (validateOn: submit)", () => {
1425+
const el = makeForm(basicHtml);
1426+
const form = createForm({
1427+
el,
1428+
schema: basicSchema,
1429+
onSubmit: () => {},
1430+
validateOn: "submit",
1431+
});
1432+
const unmount = form.mount();
1433+
1434+
// no prior submission — errors are empty
1435+
const input = setField(el, "email", "bad");
1436+
dispatch(input, "input");
1437+
1438+
// should stay empty — no proactive validation when clean
1439+
expect(form.errors()).toEqual({});
1440+
unmount();
1441+
cleanup(el);
1442+
});
1443+
});

packages/form/src/index.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,13 @@ export type FormErrors = Record<string, string[]>;
7070
/**
7171
* When to run schema validation automatically against field changes.
7272
*
73-
* - `"submit"` (default) — only on form submission
74-
* - `"change"` — on `change` events (after a field loses focus with a new value)
75-
* - `"input"` — on every `input` event (every keystroke)
73+
* - `"submit"` (default) — defers validation to submission while the form is
74+
* clean (no active errors). Once a submission fails, the form automatically
75+
* revalidates on every `change` and `input` event until all errors are
76+
* cleared, at which point it returns to submit-only validation.
77+
* - `"change"` — validates on every `change` event (and on `input` when errors
78+
* are active).
79+
* - `"input"` — validates on every `input` event (every keystroke).
7680
*/
7781
export type ValidateOn = "submit" | "change" | "input";
7882

@@ -347,11 +351,8 @@ export function createForm<S extends StandardSchemaV1>(options: CreateFormOption
347351

348352
mount() {
349353
dirty = false;
350-
currentErrors = {}; // always reset on every mount
354+
currentErrors = {};
351355

352-
// Tracks the latest value per field — seeded with defaults, updated on
353-
// change/input. The MutationObserver restores these after re-renders so
354-
// user-edited values survive DOM reconstruction.
355356
const trackedValues = new Map<string, string | string[]>();
356357

357358
if (defaultValues) {
@@ -402,15 +403,31 @@ export function createForm<S extends StandardSchemaV1>(options: CreateFormOption
402403
}
403404
}
404405

406+
function runFieldValidationIfErrors(): void {
407+
if (Object.keys(currentErrors).length > 0) runFieldValidation();
408+
}
409+
405410
on(el, "submit", (e) => runSubmit(e as SubmitEvent));
411+
406412
on(el, "change", () => {
407413
trackCurrentValues();
408414
markDirty();
415+
if (validateOn === "change") {
416+
runFieldValidation();
417+
} else {
418+
runFieldValidationIfErrors();
419+
}
409420
});
410-
on(el, "input", trackCurrentValues); // always track on input, regardless of validateOn
411421

412-
if (validateOn === "change") on(el, "change", runFieldValidation);
413-
if (validateOn === "input") on(el, "input", runFieldValidation);
422+
on(el, "input", () => {
423+
trackCurrentValues();
424+
markDirty();
425+
if (validateOn === "input") {
426+
runFieldValidation();
427+
} else {
428+
runFieldValidationIfErrors();
429+
}
430+
});
414431

415432
let observer: MutationObserver | null = null;
416433
if (defaultValues) {

0 commit comments

Comments
 (0)