NgForm.addControl/addFormGroup call container.registerControl without a null guard (unlike removeControl/removeFormGroup) → crash when a nested ngModelGroup is destroyed before its deferred microtask · Issue #69440 · angular/angular · GitHub
Skip to content

NgForm.addControl/addFormGroup call container.registerControl without a null guard (unlike removeControl/removeFormGroup) → crash when a nested ngModelGroup is destroyed before its deferred microtask #69440

Description

@gregkaczan

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: formsopen for contributionsAn issue that is suitable for a community contributor (based on its complexity/scope).

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions