fix(forms): allow multiple async validators

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.
This commit is contained in:
Matthieu Riegler
2026-07-02 17:47:43 +02:00
committed by Leon Senft
parent cd8351a3af
commit 953cdfd66a
4 changed files with 80 additions and 16 deletions
+5 -1
View File
@@ -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';
+22 -14
View File
@@ -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. */
@@ -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();
});
});
@@ -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();
});
});