Which @angular/* package(s) are the source of the bug?
forms
Is this a regression?
No — the asymmetry has existed for a long time and is still present on main.
Description
In template-driven forms, NgForm.addControl and NgForm.addFormGroup register into the parent container inside a deferred microtask via container.registerControl(...) without a null guard, while the matching removeControl/removeFormGroup are null-safe (container?.):
https://github.com/angular/angular/blob/main/packages/forms/src/directives/ng_form.ts
addControl(dir: NgModel): void {
resolvedPromise.then(() => {
const container = this._findContainer(dir.path);
(dir as Writable<NgModel>).control =
container.registerControl(dir.name, dir.control) as FormControl; // <-- unguarded
...
});
}
addFormGroup(dir: NgModelGroup): void {
resolvedPromise.then(() => {
const container = this._findContainer(dir.path);
const group = new FormGroup({});
setUpFormContainer(group, dir);
container.registerControl(dir.name, group); // <-- unguarded
...
});
}
removeControl(dir: NgModel): void {
resolvedPromise.then(() => {
const container = this._findContainer(dir.path);
container?.removeControl(dir.name); // <-- null-safe
...
});
}
removeFormGroup(dir: NgModelGroup): void {
resolvedPromise.then(() => {
const container = this._findContainer(dir.path);
container?.removeControl?.(dir.name); // <-- null-safe
});
}
_findContainer returns null for a nested control (one inside an ngModelGroup, a viewProviders-injected ControlContainer child, or a dotted name="a.b") when the parent group no longer exists:
private _findContainer(path: string[]): FormGroup {
path.pop();
return path.length ? (this.form.get(path) as FormGroup) : this.form;
}
Note its return type is annotated FormGroup even though this.form.get(path) is nullable — so the null possibility is hidden from the type system, which is presumably why the add path was never guarded.
Because the work is deferred to a microtask, the crash fires when a nested control is destroyed in the same change-detection tick that its add-microtask was queued — e.g. an @switch/@if that swaps which ngModelGroup-bearing child is shown, an @for row removal/reorder, or a dialog/route teardown right after a value change. By the time the queued addControl/addFormGroup microtask runs, the parent group is gone, _findContainer returns null, and null.registerControl(...) throws.
The remove path already accepts that the container can be null at microtask time; the add path should too. This is a common production crash (it surfaces with no actionable stack trace once minified — just Cannot read properties of null (reading 'registerControl')).
Please provide a link to a minimal reproduction
I can't host a StackBlitz here, but the bug is reproducible directly against NgForm without any DOM. This Jasmine spec throws on current @angular/forms and passes once addControl/addFormGroup are made null-safe:
import { FormControl, NgForm, NgModel } from "@angular/forms";
it("throws when a nested control registers after its container is gone", async () => {
const form = new NgForm([], [], "always");
// A nested directive (path length > 1) whose parent group "gone" does not
// exist on the form — mirrors an ngModel inside an ngModelGroup that was
// destroyed in the same tick the control's add-microtask was queued.
const dir = {
path: ["gone", "field"],
name: "field",
control: new FormControl(""),
_setupWithForm: () => undefined,
} as unknown as NgModel;
const rejections: unknown[] = [];
const handler = (e: PromiseRejectionEvent) => { rejections.push(e.reason); e.preventDefault(); };
window.addEventListener("unhandledrejection", handler);
form.addControl(dir); // defers registerControl to a microtask
await new Promise((resolve) => setTimeout(resolve)); // let the microtask run
window.removeEventListener("unhandledrejection", handler);
expect(rejections).toEqual([]); // FAILS today: TypeError: Cannot read properties of null (reading 'registerControl')
});
Equivalent template-driven repro: an <input ngModel name="x"> nested inside <div ngModelGroup="g">, where g is removed by an @if/@switch in the same tick the input is added (e.g. a check-type or connection-type picker that swaps form sub-components). Toggling fast enough that the parent group's teardown precedes the child's add-microtask reproduces the crash.
Please provide the exception or error you saw
TypeError: Cannot read properties of null (reading 'registerControl')
at ng_form.ts (NgForm.addControl / addFormGroup microtask)
Please provide the environment you discovered this bug in
Angular: 22.0.1 (@angular/core, @angular/forms)
Forms: template-driven (NgForm + ngModel + ngModelGroup), zoneless change detection
Confirmed by reading source that the same unguarded calls are present on main.
Anything else?
Suggested fix — make the add path symmetric with the remove path (and correct the _findContainer return type to FormGroup | null):
addControl(dir: NgModel): void {
resolvedPromise.then(() => {
const container = this._findContainer(dir.path);
if (!container) return; // matches removeControl's container?. behavior
(dir as Writable<NgModel>).control = container.registerControl(dir.name, dir.control) as FormControl;
dir._setupWithForm(this.callSetDisabledState);
dir.control.updateValueAndValidity({emitEvent: false});
this._directives.add(dir);
});
}
addFormGroup(dir: NgModelGroup): void {
resolvedPromise.then(() => {
const container = this._findContainer(dir.path);
if (!container) return;
const group = new FormGroup({});
setUpFormContainer(group, dir);
container.registerControl(dir.name, group);
group.updateValueAndValidity({emitEvent: false});
});
}
Skipping registration is correct here: the directive's host view is being torn down, so there is no live container to attach to — exactly the situation the remove path already tolerates. Happy to open a PR if the team agrees with the approach.
Which @angular/* package(s) are the source of the bug?
forms
Is this a regression?
No — the asymmetry has existed for a long time and is still present on
main.Description
In template-driven forms,
NgForm.addControlandNgForm.addFormGroupregister into the parent container inside a deferred microtask viacontainer.registerControl(...)without a null guard, while the matchingremoveControl/removeFormGroupare null-safe (container?.):https://github.com/angular/angular/blob/main/packages/forms/src/directives/ng_form.ts
_findContainerreturnsnullfor a nested control (one inside anngModelGroup, aviewProviders-injectedControlContainerchild, or a dottedname="a.b") when the parent group no longer exists:Note its return type is annotated
FormGroupeven thoughthis.form.get(path)is nullable — so the null possibility is hidden from the type system, which is presumably why the add path was never guarded.Because the work is deferred to a microtask, the crash fires when a nested control is destroyed in the same change-detection tick that its add-microtask was queued — e.g. an
@switch/@ifthat swaps whichngModelGroup-bearing child is shown, an@forrow removal/reorder, or a dialog/route teardown right after a value change. By the time the queuedaddControl/addFormGroupmicrotask runs, the parent group is gone,_findContainerreturnsnull, andnull.registerControl(...)throws.The remove path already accepts that the container can be
nullat microtask time; the add path should too. This is a common production crash (it surfaces with no actionable stack trace once minified — justCannot read properties of null (reading 'registerControl')).Please provide a link to a minimal reproduction
I can't host a StackBlitz here, but the bug is reproducible directly against
NgFormwithout any DOM. This Jasmine spec throws on current@angular/formsand passes onceaddControl/addFormGroupare made null-safe:Equivalent template-driven repro: an
<input ngModel name="x">nested inside<div ngModelGroup="g">, wheregis removed by an@if/@switchin the same tick the input is added (e.g. a check-type or connection-type picker that swaps form sub-components). Toggling fast enough that the parent group's teardown precedes the child's add-microtask reproduces the crash.Please provide the exception or error you saw
Please provide the environment you discovered this bug in
Confirmed by reading source that the same unguarded calls are present on
main.Anything else?
Suggested fix — make the add path symmetric with the remove path (and correct the
_findContainerreturn type toFormGroup | null):Skipping registration is correct here: the directive's host view is being torn down, so there is no live container to attach to — exactly the situation the remove path already tolerates. Happy to open a PR if the team agrees with the approach.