Skip to content

Commit 0ea50ff

Browse files
committed
fix(forms): ensure debounced async validators produce pending status during debounce
When using a debounced async validator, the pending status from the internal debounced resource was not flowing through to the resource created by the factory. Replicate the 'chain' logic using the new privately exported ɵchain function to propagate the loading status correctly. Fixes #68105
1 parent c84642a commit 0ea50ff

4 files changed

Lines changed: 68 additions & 14 deletions

File tree

packages/core/src/core_private_export.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ export {
153153
SharedStylesHost as ɵSharedStylesHost,
154154
} from './render3/interfaces/shared_styles_host';
155155
export {
156+
chain as ɵchain,
156157
encapsulateResourceError as ɵencapsulateResourceError,
157158
ResourceImpl as ɵResourceImpl,
158159
} from './resource/resource';

packages/core/src/resource/resource.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -598,19 +598,21 @@ class ResourceWrappedError extends Error {
598598
* of the other resource if it is available, or propagating the status to the current resource if it
599599
* is not.
600600
*/
601+
export function chain<T>(resource: Resource<T>): T {
602+
switch (resource.status()) {
603+
case 'idle':
604+
throw ResourceParamsStatus.IDLE;
605+
case 'error':
606+
throw new ResourceDependencyError(resource);
607+
case 'loading':
608+
case 'reloading':
609+
throw ResourceParamsStatus.LOADING;
610+
}
611+
return resource.value();
612+
}
613+
601614
export const paramsContext: ResourceParamsContext = {
602-
chain<T>(resource: Resource<T>): T {
603-
switch (resource.status()) {
604-
case 'idle':
605-
throw ResourceParamsStatus.IDLE;
606-
case 'error':
607-
throw new ResourceDependencyError(resource);
608-
case 'loading':
609-
case 'reloading':
610-
throw ResourceParamsStatus.LOADING;
611-
}
612-
return resource.value();
613-
},
615+
chain,
614616
};
615617

616618
let inParamsFunction = false;

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

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

9-
import {DebounceTimer, ResourceRef, ResourceSnapshot, Signal, debounced} from '@angular/core';
9+
import {
10+
computed,
11+
DebounceTimer,
12+
ResourceRef,
13+
ResourceSnapshot,
14+
Signal,
15+
debounced,
16+
ɵchain,
17+
} from '@angular/core';
1018
import {FieldNode} from '../../../field/node';
1119
import {addDefaultField} from '../../../field/validation';
1220
import {FieldPathNode} from '../../../schema/path_node';
@@ -127,7 +135,8 @@ export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKi
127135
(_state, params) => {
128136
if (opts.debounce !== undefined) {
129137
const debouncedResource = debounced(() => params(), opts.debounce);
130-
return opts.factory(debouncedResource.value);
138+
const wrappedParams = computed(() => ɵchain(debouncedResource));
139+
return opts.factory(wrappedParams);
131140
}
132141
return opts.factory(params);
133142
},

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import {ApplicationRef, computed, Injector, Resource, resource, signal} from '@angular/core';
1010
import {TestBed} from '@angular/core/testing';
11+
import {timeout, useAutoTick} from '@angular/private/testing';
12+
1113
import {
1214
form,
1315
NgValidationError,
@@ -195,6 +197,7 @@ describe('validation status', () => {
195197
});
196198

197199
describe('async validator', () => {
200+
useAutoTick();
198201
it('should affect validity of host field if no target specified', async () => {
199202
let res: Resource<unknown>;
200203

@@ -439,6 +442,45 @@ describe('validation status', () => {
439442
expect(f().valid()).toBe(false);
440443
expect(f().invalid()).toBe(true);
441444
});
445+
446+
it('should produce pending status during debounce period', async () => {
447+
let res: Resource<unknown>;
448+
449+
const f = form(
450+
signal('VALID'),
451+
(p) => {
452+
validateAsync(p, {
453+
params: ({value}) => value(),
454+
debounce: 100,
455+
factory: (params) =>
456+
(res = resource({
457+
params,
458+
loader: ({params}) =>
459+
new Promise<ValidationError[]>((r) =>
460+
setTimeout(() => r(validateValueForChild(params, undefined))),
461+
),
462+
})),
463+
onSuccess: (results) => results,
464+
onError: () => null,
465+
});
466+
},
467+
{injector},
468+
);
469+
470+
await appRef.whenStable();
471+
expect(f().pending()).toBe(false);
472+
473+
f().value.set('INVALID');
474+
await appRef.whenStable();
475+
476+
expect(f().pending()).withContext('pending during debounce').toBe(true);
477+
478+
await timeout(150);
479+
await appRef.whenStable();
480+
481+
expect(f().pending()).toBe(false);
482+
expect(f().invalid()).toBe(true);
483+
});
442484
});
443485

444486
describe('multiple validators', () => {

0 commit comments

Comments
 (0)