From f4227ac1a6c52895584bee5de74d4f4a6e21d7de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sat, 17 Oct 2026 20:00:19 -0300 Subject: [PATCH] fix(forms): safely chain debounced async validation resources Pass debounced params to async validator factories without exposing resource status sentinels. Compose snapshots separately to preserve pending and error states, and delegate reloads after debounce settles. Add regression coverage for effects, custom snapshots, params error recovery, and reloadValidation. Fixes #70884 --- .../api/rules/validation/validate_async.ts | 51 ++++- .../forms/signals/test/node/resource.spec.ts | 206 +++++++++++++++++- 2 files changed, 253 insertions(+), 4 deletions(-) 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(