Skip to content

forms: ngModelGroup status classes never update in OnPush views when the group has no rendered controls (zoneless) #69310

@gregkaczan

Description

@gregkaczan

Which @angular/* package(s) are the source of the bug?

forms

Is this a regression?

No — long-standing in spirit (NgControlStatus vs OnPush, #11563, filed 2016). The signal-backed control state added for signal-forms interop fixes most template shapes, but not this one.

Description

NgForm registers ngModel / ngModelGroup controls in a deferred microtask (resolvedPromise.then(...) in NgForm.addControl / addFormGroup / removeControlng_form.ts), without marking any view dirty.

NgControlStatusGroup's host bindings (AbstractControlStatus, ng_control_status.ts) create reactive dependencies by reading the control's internal signals:

get isTouched() {
  this._cd?.control?._touched?.();   // signal read → dependency
  return !!this._cd?.control?.touched;
}

When the host bindings are first checked before the deferred registration has run, control is still undefined, the optional chain short-circuits, and no signal dependency is created — so nothing ever notifies the view about the group's state.

If any control registers beneath the group (an <input ngModel> in the same view or in a child view), the registration wave's signal writes end up waking the relevant views and the group host recovers. But when the group has no rendered controls — the classic collapsed accordion/section pattern, where the group element renders and its content is @if-ed out — there is nothing to wake the view, ever:

  • the group is correctly registered in the model (form.get('...') exists, valid/touched/pristine are all correct), but
  • the group host's ng-untouched / ng-pristine / ng-valid classes never appear, and later model changes (e.g. markAllAsTouched()) are never reflected either — permanently stale DOM in zoneless OnPush apps.

Side observation: isUntouched, isDirty, isInvalid and isPending don't read the corresponding signals at all (their positive counterparts do). They're rescued today by being evaluated in the same host-binding pass, but the asymmetry looks unintentional.

What the reproduction shows (link below; Angular 21.2.16, zoneless): two ngModelGroup boxes in a real form, styled purely via their status classes (.ng-valid green / .ng-invalid red / .ng-touched yellow), with the live model state printed under each:

  • BROKEN (OnPush view, group content not rendered — "collapsed section"): model says valid=true touched=false → the box should be green; it has no ng-* classes at all, ever.
  • CONTROL (identical OnPush component, input rendered in the same view): correctly red (ng-invalid).
  • Click form.markAllAsTouched(): the model reports touched=true for both groups; the CONTROL box turns yellow (ng-touched); the BROKEN box still shows nothing.

The essential shape:

@Component({
  selector: 'broken-group',
  // a "collapsed section": the group host renders, its inputs do not
  template: `<div ngModelGroup="broken">collapsed section</div>`,
  imports: [FormsModule],
  viewProviders: [{ provide: ControlContainer, useExisting: NgForm }],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
class BrokenGroupComponent {}

@Component({
  selector: 'app-root',
  template: `<form><broken-group /></form>`,
  imports: [FormsModule, BrokenGroupComponent],
})
class AppComponent {}

bootstrapApplication(AppComponent, {
  providers: [provideZonelessChangeDetection()],
});

Remove changeDetection: OnPush, or render an <input ngModel> inside the group, and the classes behave as expected.

NG0100 flavor under exhaustive checkNoChanges: the same stale binding makes provideCheckNoChangesConfig({exhaustive: true}) — the recommended dev-time net for zoneless apps — throw NG0100 on every tick after the deferred registration lands. TestBed reproduction (verified on 21.2.16, ChromeHeadless):

it("throws NG0100 for ngModelGroup ng-* classes (exhaustive checkNoChanges)", async () => {
  TestBed.configureTestingModule({
    providers: [
      provideZonelessChangeDetection(),
      provideCheckNoChangesConfig({ exhaustive: true }),
    ],
  });
  const appRef = TestBed.inject(ApplicationRef);
  const host = document.createElement("div");
  document.body.appendChild(host);
  const compRef = createComponent(AppComponent, {  // <form><broken-group/></form> as above
    environmentInjector: TestBed.inject(EnvironmentInjector),
    hostElement: host,
  });
  appRef.attachView(compRef.hostView);
  appRef.tick();
  await new Promise<void>((resolve) => setTimeout(resolve)); // let NgForm's deferred registration run
  expect(() => appRef.tick()).toThrowError(/NG0100/);
  host.remove();
});

In our application (large template-driven-forms app, ~30 templates with ngModelGroup in OnPush components, per-item groups with collapsible content) this throws on every render of a collapsed section. The only workaround is exhaustive: false, which also hides real findings.

With v22 defaulting components to OnPush and pushing zoneless, this shape stops being exotic. Possible directions:

  1. Have NgForm's deferred registration notify the registering directive's host view (mark it for refresh once the resolvedPromise.then lands), or
  2. give AbstractControlStatus a dependency it can track before the control exists (e.g. a "control resolved" signal on the directive), and make the negative getters (isUntouched/isDirty/isInvalid/isPending) read the same signals as their positive counterparts;
  3. at minimum, document on provideCheckNoChangesConfig that exhaustive mode produces unavoidable NG0100s with template-driven forms in OnPush views of this shape.

Please provide a link to a minimal reproduction of the bug

https://stackblitz.com/edit/1lsi1efp?file=src%2Fmain.ts

Please provide the exception or error you saw

ERROR RuntimeError: NG0100: ExpressionChangedAfterItHasBeenCheckedError:
Expression has changed after it was checked. Previous value for 'ng-untouched':
'false'. Current value: 'true'. Expression location: _ContractFormColumnComponent component.
    at throwErrorIfNoChangesMode (core)
    at bindingUpdated (core)
    at checkStylingProperty (core)
    at ɵɵclassProp (core)
    at NgControlStatusGroup_HostBindings (forms.mjs:629)

(Only under provideCheckNoChangesConfig({exhaustive: true}); the stale-classes flavor needs no extra config.)

Please provide the environment you discovered this bug in (run ng version)

Angular CLI: 21.2.x
Angular: 21.2.16 (@angular/core, @angular/forms)
Zoneless: provideZonelessChangeDetection(), no zone.js

Deferred-registration code unchanged in packages/forms/src/directives/ng_form.ts on main.

Anything else?

Found while migrating a large template-driven-forms app to zoneless. The form model is always correct — only the DOM class mirror on the group host goes permanently stale, which makes it a silent, hard-to-diagnose failure for anyone styling ng-* classes on group hosts (e.g. invalid-section highlighting on collapsible form sections).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: formsgemini-triagedLabel noting that an issue has been triaged by gemini

    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