Skip to content

Commit 03201fc

Browse files
committed
test(forms): cancel pending async validators on submission
Prevent a validation that is pending at the time of submission from overriding submission errors when it resolves.
1 parent 8bbe6dc commit 03201fc

5 files changed

Lines changed: 122 additions & 9 deletions

File tree

packages/forms/signals/src/api/rules/validation/validate_async.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {ResourceRef, Signal} from '@angular/core';
9+
import {ResourceRef, Signal, WritableSignal, linkedSignal} from '@angular/core';
1010
import {FieldNode} from '../../../field/node';
11-
import {addDefaultField} from '../../../field/validation';
11+
import {PENDING_VALIDATION_PARAMS, addDefaultField} from '../../../field/validation';
1212
import {FieldPathNode} from '../../../schema/path_node';
1313
import {assertPathIsCurrent} from '../../../schema/schema';
1414
import {
@@ -117,6 +117,15 @@ export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKi
117117
assertPathIsCurrent(path);
118118
const pathNode = FieldPathNode.unwrapFieldPath(path);
119119

120+
// Wrap the parameters in a linked signal so they can be overridden with `undefined` to cancel the
121+
// validation on submission.
122+
const PARAMS = createManagedMetadataKey<WritableSignal<TParams | undefined>, TParams>(
123+
linkedSignal,
124+
);
125+
metadata(path, PARAMS, (ctx) => opts.params(ctx));
126+
// Add the linked signal to the list of all pending validations.
127+
metadata(path, PENDING_VALIDATION_PARAMS, (ctx) => ctx.state.metadata(PARAMS));
128+
120129
const RESOURCE = createManagedMetadataKey<ReturnType<typeof opts.factory>, TParams | undefined>(
121130
opts.factory,
122131
);
@@ -126,7 +135,7 @@ export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKi
126135
if (validationState.shouldSkipValidation() || !validationState.syncValid()) {
127136
return undefined;
128137
}
129-
return opts.params(ctx);
138+
return ctx.state.metadata(PARAMS)!();
130139
});
131140

132141
pathNode.builder.addAsyncErrorRule((ctx) => {

packages/forms/signals/src/api/structure.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ export async function submit<TModel>(
379379
node.submitState.selfSubmitting.set(true);
380380
try {
381381
const errors = await action(form);
382+
node.cancelPendingValidation();
382383
errors && setSubmissionErrors(node, errors);
383384
} finally {
384385
node.submitState.selfSubmitting.set(false);

packages/forms/signals/src/field/node.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
type TrackingKey,
3737
} from './structure';
3838
import {FieldSubmitState} from './submit';
39-
import {ValidationState} from './validation';
39+
import {PENDING_VALIDATION_PARAMS, ValidationState} from './validation';
4040

4141
/**
4242
* Internal node in the form tree for a given field.
@@ -229,6 +229,24 @@ export class FieldNode implements FieldState<unknown> {
229229
return this.metadata(REQUIRED) ?? FALSE;
230230
}
231231

232+
/**
233+
* Cancels any pending async validations for this field and its descendants.
234+
*/
235+
cancelPendingValidation(): void {
236+
if (!this.pending()) {
237+
return;
238+
}
239+
240+
if (this.hasMetadata(PENDING_VALIDATION_PARAMS)) {
241+
// Setting the resource params to `undefined` cancels the pending validation.
242+
this.metadata(PENDING_VALIDATION_PARAMS)!().forEach((params) => params.set(undefined));
243+
}
244+
245+
for (const child of this.structure.children()) {
246+
child.cancelPendingValidation();
247+
}
248+
}
249+
232250
metadata<M>(key: MetadataKey<M, any, any>): M | undefined {
233251
return this.metadataState.get(key);
234252
}
@@ -257,8 +275,6 @@ export class FieldNode implements FieldState<unknown> {
257275
/**
258276
* Resets the {@link touched} and {@link dirty} state of the field and its descendants.
259277
*
260-
* Note this does not change the data model, which can be reset directly if desired.
261-
*
262278
* @param value Optional value to set to the form. If not passed, the value will not be changed.
263279
*/
264280
reset(value?: unknown): void {

packages/forms/signals/src/field/validation.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,25 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {computed, Signal, ɵWritable} from '@angular/core';
9+
import {computed, Signal, WritableSignal, ɵWritable} from '@angular/core';
10+
import {createMetadataKey, MetadataKey, MetadataReducer} from '../api/rules/metadata';
1011
import type {ValidationError} from '../api/rules/validation/validation_errors';
1112
import type {FieldTree, TreeValidationResult, ValidationResult} from '../api/types';
1213
import {isArray} from '../util/type_guards';
1314
import type {FieldNode} from './node';
1415
import {shortCircuitFalse} from './util';
1516

17+
/**
18+
* A private {@link MetadataKey} used to accumulate `validateAsync()` parameters.
19+
*
20+
* This is used to cancel all pending validations when the field is submitted.
21+
*/
22+
export const PENDING_VALIDATION_PARAMS: MetadataKey<
23+
Signal<WritableSignal<unknown | undefined>[]>,
24+
WritableSignal<unknown | undefined> | undefined,
25+
WritableSignal<unknown | undefined>[]
26+
> = createMetadataKey(MetadataReducer.list());
27+
1628
/**
1729
* Helper function taking validation state, and returning own state of the node.
1830
* @param state

packages/forms/signals/test/node/submit.spec.ts

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {Injector, resource, signal} from '@angular/core';
9+
import {ApplicationRef, Injector, resource, signal} from '@angular/core';
1010
import {TestBed} from '@angular/core/testing';
1111
import {
1212
form,
@@ -18,6 +18,12 @@ import {
1818
} from '../../public_api';
1919

2020
describe('submit', () => {
21+
let appRef: ApplicationRef;
22+
23+
beforeEach(() => {
24+
appRef = TestBed.inject(ApplicationRef);
25+
});
26+
2127
it('fails fast on invalid form', async () => {
2228
const data = signal({first: '', last: ''});
2329
const f = form(
@@ -60,10 +66,79 @@ describe('submit', () => {
6066
const submitSpy = jasmine.createSpy();
6167
await submit(f, submitSpy);
6268

63-
expect(f().pending()).toBe(true);
6469
expect(submitSpy).toHaveBeenCalled();
6570
});
6671

72+
it('should cancel pending async validations on success', async () => {
73+
const data = signal('');
74+
const {promise, resolve} = promiseWithResolvers();
75+
const f = form(
76+
data,
77+
(p) => {
78+
validateAsync(p, {
79+
params: ({value}) => value(),
80+
factory: (params) =>
81+
resource({
82+
params,
83+
loader: async ({params}) => {
84+
await promise;
85+
return params;
86+
},
87+
}),
88+
onSuccess: () => ({kind: 'async'}),
89+
onError: () => ({kind: 'async-error'}),
90+
});
91+
},
92+
{injector: TestBed.inject(Injector)},
93+
);
94+
95+
expect(f().pending()).withContext('Async validator should be pending').toBe(true);
96+
97+
await submit(f, async () => {});
98+
expect(f().pending()).withContext('Submission should cancel pending validators').toBe(false);
99+
expect(f().valid()).toBe(true);
100+
101+
resolve(undefined);
102+
await appRef.whenStable();
103+
expect(f().valid()).toBe(true);
104+
});
105+
106+
it('should cancel pending async validations on error', async () => {
107+
const data = signal('');
108+
const {promise, resolve} = promiseWithResolvers();
109+
const f = form(
110+
data,
111+
(p) => {
112+
validateAsync(p, {
113+
params: ({value}) => value(),
114+
factory: (params) =>
115+
resource({
116+
params,
117+
loader: async ({params}) => {
118+
await promise;
119+
return params;
120+
},
121+
}),
122+
onSuccess: () => ({kind: 'async'}),
123+
onError: () => ({kind: 'async-error'}),
124+
});
125+
},
126+
{injector: TestBed.inject(Injector)},
127+
);
128+
129+
expect(f().pending()).withContext('Async validator should be pending').toBe(true);
130+
131+
await submit(f, async () => ({kind: 'submit'}));
132+
expect(f().pending()).withContext('Submission should cancel pending validators').toBe(false);
133+
expect(f().errors()).toEqual([jasmine.objectContaining({kind: 'submit'})]);
134+
135+
resolve(undefined);
136+
await appRef.whenStable();
137+
expect(f().errors())
138+
.withContext('Resolving the pending validator should not clear submission errors')
139+
.toEqual([jasmine.objectContaining({kind: 'submit'})]);
140+
});
141+
67142
it('maps error to a field', async () => {
68143
const data = signal({first: '', last: ''});
69144
const f = form(

0 commit comments

Comments
 (0)