Skip to content

Commit bc5ca79

Browse files
committed
fix(core): ignore stale form submit attempts
1 parent 2980659 commit bc5ca79

File tree

5 files changed

+130
-0
lines changed

5 files changed

+130
-0
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` now ignores late async validation results and submit rejections after `reset()` cancels a pending submit attempt, keeping reset state authoritative.
12+
13+
### Tests
14+
15+
- Added `useForm` regressions covering reset during pending submit rejection and reset during pending async-submit validation completion.
16+
917
## [0.1.0-alpha.60] - 2026-03-14
1018

1119
### Bug Fixes

packages/core/src/forms/__tests__/form.async-validation.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,68 @@ describe("form.async-validation - useForm behavior", () => {
479479
assert.equal(form.isSubmitting, false);
480480
});
481481

482+
test("reset ignores late async validation errors from a canceled submit attempt", async () => {
483+
const h = createFormHarness();
484+
const validation = createDeferred<ValidationResult<Values>>();
485+
let submitCalls = 0;
486+
const opts = options({
487+
validateOnChange: false,
488+
validate: () => ({}),
489+
validateAsync: async () => validation.promise,
490+
onSubmit: () => {
491+
submitCalls++;
492+
},
493+
});
494+
495+
let form = h.render(opts);
496+
form.handleSubmit();
497+
await flushMicrotasks();
498+
form = h.render(opts);
499+
assert.equal(form.isSubmitting, true);
500+
501+
form.reset();
502+
form = h.render(opts);
503+
assert.equal(form.isSubmitting, false);
504+
assert.deepEqual(form.errors, {});
505+
506+
validation.resolve({ email: "taken" });
507+
await flushMicrotasks(4);
508+
form = h.render(opts);
509+
510+
assert.equal(form.isSubmitting, false);
511+
assert.deepEqual(form.errors, {});
512+
assert.equal(form.submitError, undefined);
513+
assert.equal(submitCalls, 0);
514+
});
515+
516+
test("reset ignores late async validation rejection from a canceled submit attempt", async () => {
517+
const h = createFormHarness();
518+
const validation = createDeferred<ValidationResult<Values>>();
519+
const opts = options({
520+
validateOnChange: false,
521+
validate: () => ({}),
522+
validateAsync: async () => validation.promise,
523+
});
524+
525+
let form = h.render(opts);
526+
form.handleSubmit();
527+
await flushMicrotasks();
528+
form = h.render(opts);
529+
assert.equal(form.isSubmitting, true);
530+
531+
form.reset();
532+
form = h.render(opts);
533+
assert.equal(form.isSubmitting, false);
534+
535+
validation.reject(new Error("offline"));
536+
await flushMicrotasks(4);
537+
form = h.render(opts);
538+
539+
assert.equal(form.isSubmitting, false);
540+
assert.equal(form.submitError, undefined);
541+
assert.deepEqual(form.errors, {});
542+
});
543+
482544
test("handleSubmit rejects concurrent submits while pending", async () => {
483545
const h = createFormHarness();
484546
const submit = createDeferred<void>();

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,43 @@ describe("form.state - submit and reset lifecycle", () => {
346346
await flushMicrotasks();
347347
form = h.render(opts);
348348
assert.equal(form.isSubmitting, false);
349+
assert.deepEqual(form.values, { name: "Ada", email: "ada@example.com", age: 42 });
350+
assert.equal(form.submitError, undefined);
351+
});
352+
353+
test("reset ignores late submit rejection from a canceled attempt", async () => {
354+
const h = createFormHarness();
355+
const submit = createDeferred<void>();
356+
const submitError = new Error("submit failed");
357+
const captured: unknown[] = [];
358+
const opts = options({
359+
onSubmit: async () => submit.promise,
360+
onSubmitError: (error) => {
361+
captured.push(error);
362+
},
363+
});
364+
365+
let form = h.render(opts);
366+
form.setFieldValue("name", "Changed");
367+
form = h.render(opts);
368+
form.handleSubmit();
369+
await flushMicrotasks();
370+
form = h.render(opts);
371+
assert.equal(form.isSubmitting, true);
372+
373+
form.reset();
374+
form = h.render(opts);
375+
assert.equal(form.isSubmitting, false);
376+
assert.deepEqual(form.values, { name: "Ada", email: "ada@example.com", age: 42 });
377+
378+
submit.reject(submitError);
379+
await flushMicrotasks(4);
380+
form = h.render(opts);
381+
382+
assert.equal(form.isSubmitting, false);
383+
assert.equal(form.submitError, undefined);
384+
assert.deepEqual(form.values, { name: "Ada", email: "ada@example.com", age: 42 });
385+
assert.deepEqual(captured, []);
349386
});
350387

351388
test("resetOnSubmit restores initial values after successful submit", async () => {

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ type AsyncValidatorRef<T extends Record<string, unknown>> = {
1717
| undefined;
1818
};
1919

20+
type AttemptRef = { current: number };
21+
2022
function markAllFieldsTouched<T extends Record<string, unknown>>(
2123
values: T,
2224
): Partial<Record<keyof T, FieldBooleanValue>> {
@@ -32,11 +34,13 @@ function markAllFieldsTouched<T extends Record<string, unknown>>(
3234
export function createResetAction<T extends Record<string, unknown>>(options: {
3335
formOptions: UseFormOptions<T>;
3436
asyncValidatorRef: AsyncValidatorRef<T>;
37+
attemptRef: AttemptRef;
3538
submittingRef: { current: boolean };
3639
fieldArrayKeysRef: { current: Partial<Record<keyof T, string[]>> };
3740
updateFormState: UpdateFormState<T>;
3841
}): () => void {
3942
return (): void => {
43+
options.attemptRef.current += 1;
4044
options.submittingRef.current = false;
4145
options.asyncValidatorRef.current?.cancel();
4246
options.fieldArrayKeysRef.current = {};
@@ -51,6 +55,7 @@ export function createSubmitAction<T extends Record<string, unknown>>(options: {
5155
stateRef: { current: FormState<T> };
5256
submittingRef: { current: boolean };
5357
asyncValidatorRef: AsyncValidatorRef<T>;
58+
attemptRef: AttemptRef;
5459
updateFormState: UpdateFormState<T>;
5560
runSyncValidationFiltered: (
5661
values: T,
@@ -94,8 +99,14 @@ export function createSubmitAction<T extends Record<string, unknown>>(options: {
9499
options.submittingRef.current = false;
95100
return;
96101
}
102+
const attempt = options.attemptRef.current + 1;
103+
options.attemptRef.current = attempt;
97104
const submitValues = cloneInitialValues(snapshot.values);
105+
const isStaleAttempt = (): boolean => options.attemptRef.current !== attempt;
98106
const failSubmit = (error: unknown): void => {
107+
if (isStaleAttempt()) {
108+
return;
109+
}
99110
if (typeof options.formOptions.onSubmitError === "function") {
100111
try {
101112
options.formOptions.onSubmitError(error);
@@ -115,6 +126,9 @@ export function createSubmitAction<T extends Record<string, unknown>>(options: {
115126
};
116127

117128
const finishSuccessfulSubmit = (): void => {
129+
if (isStaleAttempt()) {
130+
return;
131+
}
118132
if (options.formOptions.resetOnSubmit) {
119133
options.reset();
120134
return;
@@ -176,6 +190,9 @@ export function createSubmitAction<T extends Record<string, unknown>>(options: {
176190
void (async () => {
177191
try {
178192
const asyncErrors = await options.runAsyncValidationFiltered(submitValues, snapshot);
193+
if (isStaleAttempt()) {
194+
return;
195+
}
179196
const allErrors = mergeValidationErrors(syncErrors, asyncErrors);
180197
if (!isValidationClean(allErrors)) {
181198
options.submittingRef.current = false;
@@ -189,6 +206,9 @@ export function createSubmitAction<T extends Record<string, unknown>>(options: {
189206
}
190207
await runSubmitCallback();
191208
} catch (error) {
209+
if (isStaleAttempt()) {
210+
return;
211+
}
192212
options.submittingRef.current = false;
193213
options.updateFormState((prev) => ({
194214
...prev,

packages/core/src/forms/useForm.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export function useForm<T extends Record<string, unknown>, State = void>(
4747

4848
const pendingAsyncValuesRef = ctx.useRef<T | null>(null);
4949
const submittingRef = ctx.useRef(false);
50+
const submitAttemptRef = ctx.useRef(0);
5051
const validateRef = ctx.useRef(options.validate);
5152
validateRef.current = options.validate;
5253
const nonTextBindingWarningsRef = ctx.useRef<Set<string>>(new Set());
@@ -192,6 +193,7 @@ export function useForm<T extends Record<string, unknown>, State = void>(
192193
const reset = createResetAction({
193194
formOptions: options,
194195
asyncValidatorRef,
196+
attemptRef: submitAttemptRef,
195197
submittingRef,
196198
fieldArrayKeysRef,
197199
updateFormState,
@@ -204,6 +206,7 @@ export function useForm<T extends Record<string, unknown>, State = void>(
204206
stateRef,
205207
submittingRef,
206208
asyncValidatorRef,
209+
attemptRef: submitAttemptRef,
207210
updateFormState,
208211
runSyncValidationFiltered,
209212
runAsyncValidationFiltered,

0 commit comments

Comments
 (0)