Skip to content

Commit f8234a5

Browse files
committed
fix(core): clear stale wizard step errors
1 parent 2980659 commit f8234a5

File tree

3 files changed

+96
-1
lines changed

3 files changed

+96
-1
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ The format is based on Keep a Changelog and the project follows Semantic Version
66

77
## [Unreleased]
88

9+
### Bug Fixes
10+
11+
- `useForm` wizard transitions now clear stale step errors when async revalidation succeeds after a field value changes, preventing false navigation blocks.
12+
13+
### Tests
14+
15+
- Added wizard regressions for `nextStep()` and `goToStep()` when async step errors become stale after field edits.
16+
917
## [0.1.0-alpha.60] - 2026-03-14
1018

1119
### Bug Fixes

packages/core/src/forms/__tests__/form.wizard.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,82 @@ describe("useForm wizard", () => {
388388
assert.equal(form.errors.email, "Email already taken");
389389
});
390390

391+
test("nextStep clears stale async-style step errors after the field value changes", async () => {
392+
const h = createTestContext();
393+
394+
const options = createWizardOptions({
395+
initialValues: {
396+
name: "Ada",
397+
email: "taken@example.com",
398+
age: 30,
399+
},
400+
validateOnChange: false,
401+
validateAsync: async (values) =>
402+
values.email === "taken@example.com" ? { email: "Email already taken" } : {},
403+
wizard: {
404+
initialStep: 1,
405+
steps: [
406+
{ id: "step-account", fields: ["name"] },
407+
{ id: "step-contact", fields: ["email"] },
408+
{ id: "step-confirm", fields: ["age"] },
409+
],
410+
},
411+
});
412+
413+
let form = h.render(options);
414+
form.setFieldError("email", "Email already taken");
415+
form = h.render(options);
416+
form.setFieldValue("email", "fresh@example.com");
417+
form = h.render(options);
418+
419+
const moved = form.nextStep();
420+
form = h.render(options);
421+
422+
assert.equal(moved, false);
423+
assert.equal(form.currentStep, 1);
424+
assert.equal(form.errors.email, "Email already taken");
425+
426+
await flushMicrotasks();
427+
form = h.render(options);
428+
429+
assert.equal(form.currentStep, 2);
430+
assert.equal(form.errors.email, undefined);
431+
});
432+
433+
test("goToStep clears stale async-style intermediate errors after the field value changes", async () => {
434+
const h = createTestContext();
435+
436+
const options = createWizardOptions({
437+
initialValues: {
438+
name: "Ada",
439+
email: "taken@example.com",
440+
age: 30,
441+
},
442+
validateOnChange: false,
443+
validateAsync: async (values) =>
444+
values.email === "taken@example.com" ? { email: "Email already taken" } : {},
445+
});
446+
447+
let form = h.render(options);
448+
form.setFieldError("email", "Email already taken");
449+
form = h.render(options);
450+
form.setFieldValue("email", "fresh@example.com");
451+
form = h.render(options);
452+
453+
const moved = form.goToStep(2);
454+
form = h.render(options);
455+
456+
assert.equal(moved, false);
457+
assert.equal(form.currentStep, 0);
458+
assert.equal(form.errors.email, "Email already taken");
459+
460+
await flushMicrotasks();
461+
form = h.render(options);
462+
463+
assert.equal(form.currentStep, 2);
464+
assert.equal(form.errors.email, undefined);
465+
});
466+
391467
test("goToStep allows forward navigation when intermediate steps are valid", () => {
392468
const h = createTestContext();
393469

packages/core/src/forms/internal/wizard.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ export function resolveWizardTransition<T extends Record<string, unknown>>(optio
197197
transitionSteps: ReadonlyArray<WizardTransitionStep<T>>;
198198
source: WizardTransitionSource<T>;
199199
asyncErrors?: ValidationResult<T>;
200+
includeSourceErrors?: boolean;
200201
runWizardStepValidation: (
201202
values: T,
202203
stepIndex: number,
@@ -209,12 +210,18 @@ export function resolveWizardTransition<T extends Record<string, unknown>>(optio
209210
}> | null {
210211
let mergedErrors = options.source.errors as ValidationResult<T>;
211212
for (const transitionStep of options.transitionSteps) {
213+
const stepSourceErrors =
214+
options.includeSourceErrors === false
215+
? clearValidationFields(mergedErrors, transitionStep.fields)
216+
: options.asyncErrors === undefined
217+
? mergedErrors
218+
: clearValidationFields(mergedErrors, transitionStep.fields);
212219
const baseStepErrors = options.runWizardStepValidation(
213220
options.values,
214221
transitionStep.stepIndex,
215222
{
216223
...options.source,
217-
errors: mergedErrors,
224+
errors: stepSourceErrors,
218225
},
219226
);
220227
const stepErrors =
@@ -278,6 +285,7 @@ export function createWizardActions<T extends Record<string, unknown>>(options:
278285
values: snapshot.values,
279286
transitionSteps,
280287
source: snapshot,
288+
includeSourceErrors: !options.validateAsync,
281289
runWizardStepValidation: options.runWizardStepValidation,
282290
});
283291
if (blocked) {
@@ -330,6 +338,7 @@ export function createWizardActions<T extends Record<string, unknown>>(options:
330338
transitionSteps,
331339
source: options.stateRef.current,
332340
asyncErrors,
341+
includeSourceErrors: false,
333342
runWizardStepValidation: options.runWizardStepValidation,
334343
});
335344
if (asyncBlocked) {
@@ -393,6 +402,7 @@ export function createWizardActions<T extends Record<string, unknown>>(options:
393402
values: snapshot.values,
394403
transitionSteps,
395404
source: snapshot,
405+
includeSourceErrors: !options.validateAsync,
396406
runWizardStepValidation: options.runWizardStepValidation,
397407
});
398408
if (blocked) {
@@ -445,6 +455,7 @@ export function createWizardActions<T extends Record<string, unknown>>(options:
445455
transitionSteps,
446456
source: options.stateRef.current,
447457
asyncErrors,
458+
includeSourceErrors: false,
448459
runWizardStepValidation: options.runWizardStepValidation,
449460
});
450461
if (asyncBlocked) {

0 commit comments

Comments
 (0)