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 / removeControl — ng_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:
- Have
NgForm's deferred registration notify the registering directive's host view (mark it for refresh once the resolvedPromise.then lands), or
- 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;
- 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).
Which @angular/* package(s) are the source of the bug?
forms
Is this a regression?
No — long-standing in spirit (
NgControlStatusvs OnPush, #11563, filed 2016). The signal-backed control state added for signal-forms interop fixes most template shapes, but not this one.Description
NgFormregistersngModel/ngModelGroupcontrols in a deferred microtask (resolvedPromise.then(...)inNgForm.addControl/addFormGroup/removeControl—ng_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:When the host bindings are first checked before the deferred registration has run,
controlis stillundefined, 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:form.get('...')exists,valid/touched/pristineare all correct), butng-untouched/ng-pristine/ng-validclasses never appear, and later model changes (e.g.markAllAsTouched()) are never reflected either — permanently stale DOM in zoneless OnPush apps.Side observation:
isUntouched,isDirty,isInvalidandisPendingdon'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
ngModelGroupboxes in a real form, styled purely via their status classes (.ng-validgreen /.ng-invalidred /.ng-touchedyellow), with the live model state printed under each:valid=true touched=false→ the box should be green; it has nong-*classes at all, ever.ng-invalid).form.markAllAsTouched(): the model reportstouched=truefor both groups; the CONTROL box turns yellow (ng-touched); the BROKEN box still shows nothing.The essential shape:
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 — throwNG0100on every tick after the deferred registration lands. TestBed reproduction (verified on 21.2.16, ChromeHeadless):In our application (large template-driven-forms app, ~30 templates with
ngModelGroupin OnPush components, per-item groups with collapsible content) this throws on every render of a collapsed section. The only workaround isexhaustive: false, which also hides real findings.With v22 defaulting components to OnPush and pushing zoneless, this shape stops being exotic. Possible directions:
NgForm's deferred registration notify the registering directive's host view (mark it for refresh once theresolvedPromise.thenlands), orAbstractControlStatusa 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;provideCheckNoChangesConfigthat 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
(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)Deferred-registration code unchanged in
packages/forms/src/directives/ng_form.tsonmain.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).