From 953cdfd66a08e0234f7db80ba7ecaab5e3067c62 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Thu, 2 Jul 2026 17:47:43 +0200 Subject: [PATCH] fix(forms): allow multiple async validators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a parent form element defines an async validator, its resource's `params` function needs to evaluate `syncValid()`, which causes unvisited child form nodes to be lazily instantiated. If any of these lazily instantiated child nodes also define an async validator, their resource is initialized while the parent's `params` function is still evaluating. This incorrectly triggers Angular core's `NG0992` guard (`Cannot create a resource inside the params of another resource`). This commit exports `ɵsetInParamsFunction` and `ɵisInParamsFunction` from `@angular/core` and uses them in `FieldMetadataState.runMetadataCreateLifecycle` to explicitly detach the lazy creation of form metadata from the parent's reactive `params` context. --- packages/core/src/resource/index.ts | 6 +++- packages/forms/signals/src/field/metadata.ts | 36 +++++++++++-------- .../api/validators/standard_schema.spec.ts | 26 +++++++++++++- .../forms/signals/test/node/resource.spec.ts | 28 +++++++++++++++ 4 files changed, 80 insertions(+), 16 deletions(-) diff --git a/packages/core/src/resource/index.ts b/packages/core/src/resource/index.ts index 7a8022454f6..230ac83134b 100644 --- a/packages/core/src/resource/index.ts +++ b/packages/core/src/resource/index.ts @@ -9,4 +9,8 @@ export * from './api'; export {debounced} from './debounce'; export {resourceFromSnapshots} from './from_snapshots'; -export {resource} from './resource'; +export { + isInParamsFunction as ɵisInParamsFunction, + resource, + setInParamsFunction as ɵsetInParamsFunction, +} from './resource'; diff --git a/packages/forms/signals/src/field/metadata.ts b/packages/forms/signals/src/field/metadata.ts index 78a2f2181ff..96a99db1a76 100644 --- a/packages/forms/signals/src/field/metadata.ts +++ b/packages/forms/signals/src/field/metadata.ts @@ -9,8 +9,10 @@ import { computed, runInInjectionContext, - untracked, ɵRuntimeError as RuntimeError, + untracked, + ɵisInParamsFunction, + ɵsetInParamsFunction, } from '@angular/core'; import {MetadataKey} from '../api/rules/metadata'; import {RuntimeErrorCode} from '../errors'; @@ -34,20 +36,26 @@ export class FieldMetadataState { return; } - untracked(() => - runInInjectionContext(this.node.structure.injector, () => { - for (const key of this.node.logicNode.logic.getMetadataKeys()) { - if (key.create) { - const logic = this.node.logicNode.logic.getMetadata(key); - const result = key.create!( - this.node, - computed(() => logic.compute(this.node.context)), - ); - this.metadata.set(key, result); + const wasInParams = ɵisInParamsFunction(); + if (wasInParams) ɵsetInParamsFunction(false); + try { + untracked(() => + runInInjectionContext(this.node.structure.injector, () => { + for (const key of this.node.logicNode.logic.getMetadataKeys()) { + if (key.create) { + const logic = this.node.logicNode.logic.getMetadata(key); + const result = key.create!( + this.node, + computed(() => logic.compute(this.node.context)), + ); + this.metadata.set(key, result); + } } - } - }), - ); + }), + ); + } finally { + if (wasInParams) ɵsetInParamsFunction(true); + } } /** Gets the value of an `MetadataKey` for the field. */ diff --git a/packages/forms/signals/test/node/api/validators/standard_schema.spec.ts b/packages/forms/signals/test/node/api/validators/standard_schema.spec.ts index 0de070e9676..8a5671973f2 100644 --- a/packages/forms/signals/test/node/api/validators/standard_schema.spec.ts +++ b/packages/forms/signals/test/node/api/validators/standard_schema.spec.ts @@ -9,7 +9,7 @@ import {ApplicationRef, computed, Injector, linkedSignal, signal} from '@angular/core'; import {TestBed} from '@angular/core/testing'; import * as z from 'zod'; -import {form, schema, validateStandardSchema} from '../../../../public_api'; +import {form, schema, validateHttp, validateStandardSchema} from '../../../../public_api'; interface Flight { id: number; @@ -460,4 +460,28 @@ describe('standard schema integration', () => { }), ]); }); + + it('should work', () => { + const Schema = z.object({code: z.string().min(1)}); + + const injector = TestBed.inject(Injector); + const model = signal({code: ''}); + + function createForm() { + form( + model, + (p) => { + validateStandardSchema(p, Schema); // (A) schema validation + validateHttp(p.code, { + request: ({value}) => `/api/check?code=${value()}`, + onSuccess: () => null, + onError: () => null, + }); + }, + {injector}, + ); + } + + expect(createForm).not.toThrow(); + }); }); diff --git a/packages/forms/signals/test/node/resource.spec.ts b/packages/forms/signals/test/node/resource.spec.ts index dedd5265f88..3dd010af219 100644 --- a/packages/forms/signals/test/node/resource.spec.ts +++ b/packages/forms/signals/test/node/resource.spec.ts @@ -507,4 +507,32 @@ describe('resources', () => { expect(success).toBeTrue(); }); + + it('should not throw NG0992 when using validateAsync on parent and lazily-created child', () => { + const model = signal({child: ''}); + const injector = TestBed.inject(Injector); + function createForm() { + form( + model, + (p) => { + validateAsync(p, { + params: () => 1, + factory: () => resource({loader: async () => []}), + onSuccess: () => null, + onError: () => null, + }); + + validateAsync(p.child, { + params: () => 1, + factory: () => resource({loader: async () => []}), + onSuccess: () => null, + onError: () => null, + }); + }, + {injector}, + ); + } + + expect(() => createForm()).not.toThrow(); + }); });