diff --git a/packages/forms/signals/src/api/rules/validation/validate_async.ts b/packages/forms/signals/src/api/rules/validation/validate_async.ts index 4cc9682e9e99..b24fb4f9af08 100644 --- a/packages/forms/signals/src/api/rules/validation/validate_async.ts +++ b/packages/forms/signals/src/api/rules/validation/validate_async.ts @@ -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'; @@ -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. @@ -136,8 +145,15 @@ export function validateAsync { 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); }, @@ -178,3 +194,32 @@ export function validateAsync( + source: Resource, + target: Resource, +): Resource { + return Object.assign( + resourceFromSnapshots(() => { + 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; + }, + }, + ); +} diff --git a/packages/forms/signals/test/node/resource.spec.ts b/packages/forms/signals/test/node/resource.spec.ts index 3dd010af219a..cbb64294948c 100644 --- a/packages/forms/signals/test/node/resource.spec.ts +++ b/packages/forms/signals/test/node/resource.spec.ts @@ -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, @@ -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(); + 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(); + const snapshot = signal>({ + 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(); + 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(); + 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(