Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions packages/forms/signals/src/api/rules/validation/validate_async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {DebounceTimer, Resource, Signal, computed, debounced, ɵchain} from '@angular/core';
import {
DebounceTimer,
Resource,
Signal,
computed,
debounced,
resourceFromSnapshots,
} from '@angular/core';
import {FieldNode} from '../../../field/node';
import {addDefaultField} from '../../../field/validation';
import {FieldPathNode} from '../../../schema/path_node';
Expand Down Expand Up @@ -81,6 +88,8 @@ export interface AsyncValidatorOptions<
* A function that receives the resource params and returns a resource of the given params.
* The given params should be used as is to create the resource.
* The forms system will report the params as `undefined` when this validation doesn't need to be run.
* When debouncing, params keeps its previous value until the debounce completes, while validation
* remains pending.
*
* @param params The params to use for constructing the resource
* @returns A reference to the constructed resource.
Expand Down Expand Up @@ -136,8 +145,15 @@ export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKi
(_state, params) => {
if (opts.debounce !== undefined) {
const debouncedResource = debounced(() => params(), opts.debounce);
const wrappedParams = computed(() => ɵchain(debouncedResource));
return opts.factory(wrappedParams);
// `chain` relies on `resource()` params evaluation to catch its thrown status markers.
// Factories may read params elsewhere (e.g. in effects), where those markers would escape.
// Pass safe params to the factory and compose resource statuses through snapshots instead.
const debouncedParams = computed(() => {
const snapshot = debouncedResource.snapshot();
return snapshot.status === 'error' ? undefined : snapshot.value;
});
const factoryResource = opts.factory(debouncedParams);
return safeChainResources(debouncedResource, factoryResource);
}
return opts.factory(params);
},
Expand Down Expand Up @@ -178,3 +194,32 @@ export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKi
}
});
}

/** Composes two resources, propagating the source status until its value is ready. */
function safeChainResources<TSource, TResult>(
source: Resource<TSource>,
target: Resource<TResult>,
): Resource<TResult | undefined> {
return Object.assign(
resourceFromSnapshots<TResult | undefined>(() => {
const snapshot = source.snapshot();
switch (snapshot.status) {
case 'resolved':
case 'local':
return target.snapshot();
case 'error':
return snapshot;
default:
return {status: snapshot.status, value: undefined};
}
}),
{
reload: () => {
if (source.isLoading() || source.status() === 'error') {
return false;
}
return 'reload' in target && typeof target.reload === 'function' ? target.reload() : false;
},
},
);
}
206 changes: 205 additions & 1 deletion packages/forms/signals/test/node/resource.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ import {HttpTestingController, provideHttpClientTesting} from '@angular/common/h
import {
ApplicationRef,
assertNotInReactiveContext,
effect,
Injector,
resource,
resourceFromSnapshots,
ResourceSnapshot,
signal,
type Signal,
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {isNode, timeout, useAutoTick} from '@angular/private/testing';
import {isNode, timeout, useAutoTick, waitFor} from '@angular/private/testing';

import {
applyEach,
Expand Down Expand Up @@ -441,6 +444,207 @@ describe('resources', () => {
expect(usernameForm().valid()).toBe(false);
});

describe('validateAsync with debounce', () => {
it('should allow effects and snapshots to read params while debounce is pending', async () => {
const consumeParams = jasmine.createSpy('consumeParams');
const {promise: debouncePromise, resolve: resolveDebounce} = Promise.withResolvers<void>();
const model = signal('initial');
const validationForm = form(
model,
(path) => {
validateAsync(path, {
params: ({value}) => value(),
debounce: async () => debouncePromise,
factory: (params) => {
expect(params()).toBe('initial');
effect(() => consumeParams(params()));
return resourceFromSnapshots(() => ({status: 'resolved', value: params()}));
},
onSuccess: (value) => (value === 'updated' ? {kind: 'custom'} : undefined),
onError: () => undefined,
});
},
{injector},
);

await appRef.whenStable();
expect(consumeParams).toHaveBeenCalledOnceWith('initial');
expect(validationForm().valid()).toBe(true);

model.set('intermediate');
await appRef.whenStable();
expect(consumeParams).toHaveBeenCalledOnceWith('initial');
expect(validationForm().pending()).toBe(true);
expect(validationForm().valid()).toBe(false);

model.set('updated');
await appRef.whenStable();
expect(consumeParams).toHaveBeenCalledOnceWith('initial');

resolveDebounce();
await waitFor(() => {
throwUnless(consumeParams).toHaveBeenCalledTimes(2);
});
await appRef.whenStable();

expect(consumeParams.calls.allArgs()).toEqual([['initial'], ['updated']]);
expect(validationForm().pending()).toBe(false);
expect(validationForm().errors()).toEqual([{kind: 'custom', fieldTree: validationForm}]);
});

it('should propagate factory snapshots after debounce settles', async () => {
const {promise: debouncePromise, resolve: resolveDebounce} = Promise.withResolvers<void>();
const snapshot = signal<ResourceSnapshot<string | undefined>>({
status: 'idle',
value: undefined,
});
const validationForm = form(
signal('initial'),
(path) => {
validateAsync(path, {
params: ({value}) => value(),
debounce: () => debouncePromise,
factory: () => resourceFromSnapshots(snapshot),
onSuccess: (value) => ({kind: value}),
onError: () => ({kind: 'error'}),
});
},
{injector},
);

await appRef.whenStable();
expect(validationForm().valid()).toBe(true);

snapshot.set({status: 'loading', value: undefined});
await appRef.whenStable();
expect(validationForm().pending()).toBe(true);

snapshot.set({status: 'resolved', value: 'old'});
await appRef.whenStable();
expect(validationForm().errors()).toEqual([{kind: 'old', fieldTree: validationForm}]);

validationForm().value.set('updated');
await appRef.whenStable();
expect(validationForm().pending()).toBe(true);
expect(validationForm().errors()).toEqual([]);

snapshot.set({status: 'error', error: new Error('stale request failed')});
await appRef.whenStable();
expect(validationForm().pending()).toBe(true);
expect(validationForm().errors()).toEqual([]);

resolveDebounce();
await waitFor(() => {
throwUnless(validationForm().pending()).toBe(false);
});
await appRef.whenStable();
expect(validationForm().pending()).toBe(false);
expect(validationForm().errors()).toEqual([{kind: 'error', fieldTree: validationForm}]);

snapshot.set({status: 'reloading', value: 'old'});
await appRef.whenStable();
expect(validationForm().pending()).toBe(true);

snapshot.set({status: 'local', value: 'new'});
await appRef.whenStable();
expect(validationForm().pending()).toBe(false);
expect(validationForm().errors()).toEqual([{kind: 'new', fieldTree: validationForm}]);

expect(() => validationForm().reloadValidation()).not.toThrow();
});

it('should report params errors without throwing in factory effects and recover', async () => {
const {promise: debouncePromise, resolve: resolveDebounce} = Promise.withResolvers<void>();
const error = new Error('params failed');
const onError = jasmine.createSpy('onError').and.returnValue({kind: 'params'});
const consumeParams = jasmine.createSpy('consumeParams');
const validationForm = form(
signal('initial'),
(path) => {
validateAsync(path, {
params: ({value}) => {
if (value() === 'error') {
throw error;
}
return value();
},
debounce: () => debouncePromise,
factory: (params) => {
effect(() => consumeParams(params()));
return resourceFromSnapshots(() => ({status: 'resolved', value: params()}));
},
onSuccess: () => undefined,
onError,
});
},
{injector},
);

await appRef.whenStable();
expect(validationForm().valid()).toBe(true);

validationForm().value.set('error');
await appRef.whenStable();
expect(validationForm().errors()).toEqual([{kind: 'params', fieldTree: validationForm}]);
expect(onError.calls.mostRecent().args[0]).toBe(error);
expect(consumeParams.calls.mostRecent().args).toEqual([undefined]);

validationForm().value.set('recovered');
await appRef.whenStable();
resolveDebounce();
await waitFor(() => {
throwUnless(consumeParams.calls.mostRecent().args).toEqual(['recovered']);
});
await appRef.whenStable();
expect(validationForm().valid()).toBe(true);
expect(consumeParams.calls.mostRecent().args).toEqual(['recovered']);
});

it('should reload the factory resource only after debounce settles', async () => {
const {promise: debouncePromise, resolve: resolveDebounce} = Promise.withResolvers<void>();
const loader = jasmine
.createSpy('loader')
.and.callFake(async ({params}: {params: string}) => params);
const validationForm = form(
signal('initial'),
(path) => {
validateAsync(path, {
params: ({value}) => value(),
debounce: () => debouncePromise,
factory: (params) => resource({params, loader}),
onSuccess: () => undefined,
onError: () => undefined,
});
},
{injector},
);

await appRef.whenStable();
expect(validationForm().valid()).toBe(true);
expect(loader).toHaveBeenCalledTimes(1);

validationForm().reloadValidation();
await appRef.whenStable();
expect(loader).toHaveBeenCalledTimes(2);

validationForm().value.set('updated');
await appRef.whenStable();
validationForm().reloadValidation();
await appRef.whenStable();
expect(loader).toHaveBeenCalledTimes(2);
expect(validationForm().pending()).toBe(true);

resolveDebounce();
await waitFor(() => {
throwUnless(loader).toHaveBeenCalledTimes(3);
});
await appRef.whenStable();
expect(loader).toHaveBeenCalledTimes(3);
expect(loader.calls.mostRecent().args[0].params).toBe('updated');
expect(validationForm().valid()).toBe(true);
});
});

describe('reloadValidation', () => {
it('should trigger a reload of async http validation', async () => {
const usernameForm = form(
Expand Down
Loading