fix(forms): warn in dev mode when ngModel cannot reach parent NgForm across component boundary

NgModel injects ControlContainer with @Host(), which stops the injector at
the component host element boundary. When NgForm lives in a parent component
and ngModel lives in a child component, the injection returns null silently
and the control acts standalone — never registering with the form.

To surface this invisible failure, emit a dev-mode warning (NG01354) when
ngModel's @Host() injection finds nothing but the element Injector can still
reach a ControlContainer further up the hierarchy. The warning identifies the
cross-boundary issue and points developers to the viewProviders fix or the
standalone option.

Adds the NG01354 reference page explaining why the warning fires and providing
two remediation paths: bridging ControlContainer via viewProviders, or opting
out with [ngModelOptions]="{standalone: true}".

Fixes #47580
This commit is contained in:
arturovt
2026-06-30 17:27:15 +03:00
committed by Jessica Janiuk
parent 70b252cac3
commit 38d093232c
6 changed files with 332 additions and 1 deletions
@@ -0,0 +1,64 @@
# NgModel in child component cannot reach parent form
This warning is emitted when `ngModel` (or any template-driven form control) is placed inside
a child component whose parent component hosts a form directive (`NgForm` or
`FormGroupDirective`), but the control cannot register with that form.
The root cause is that `NgModel` injects `ControlContainer` with `@Host()`, which stops the
injector from crossing component boundaries. The form directive in the parent component's
template is invisible to `NgModel` in the child's template.
As a result, the child's controls silently act as standalone — they do not participate in the
parent form's value, validity, or submission state.
## Fixing the warning
### Option 1: Bridge the `ControlContainer` with `viewProviders`
Add `viewProviders` to the child component so it re-exports the parent form into its own view.
Use the same class that the warning names — `NgForm` for template-driven forms (`<form>`), or
`FormGroupDirective` for reactive forms (`[formGroup]`):
```angular-ts
// Parent uses a template-driven form: <form> ... <app-child> </form>
import {ControlContainer, NgForm} from '@angular/forms';
@Component({
selector: 'app-child',
template: `<input name="email" [(ngModel)]="email" />`,
viewProviders: [{provide: ControlContainer, useExisting: NgForm}],
})
export class Child {
email = '';
}
```
```angular-ts
// Parent uses a reactive form: <div [formGroup]="myGroup"> ... <app-child> </div>
import {ControlContainer, FormGroupDirective} from '@angular/forms';
@Component({
selector: 'app-child',
template: `<input name="email" [(ngModel)]="email" />`,
viewProviders: [{provide: ControlContainer, useExisting: FormGroupDirective}],
})
export class Child {
email = '';
}
```
The parent form then sees and validates the child's controls normally.
### Option 2: Mark the control as standalone
If the child's controls should genuinely be independent of the parent form, opt out explicitly:
```html
<input name="email" [(ngModel)]="email" [ngModelOptions]="{standalone: true}" />
```
## Debugging the warning
The warning message includes the error code `NG01354`. Use the call stack to locate which
`ngModel` triggered it. Check whether the component that owns the `<form>` tag is the same
component that owns the `ngModel` input — if they differ, apply one of the options above.
+2
View File
@@ -29,6 +29,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
NG_VALUE_ACCESSOR_NOT_PROVIDED = 1200,
// (undocumented)
NGMODEL_IN_CHILD_COMPONENT = -1354,
// (undocumented)
NGMODEL_IN_FORM_GROUP = 1350,
// (undocumented)
NGMODEL_IN_FORM_GROUP_NAME = 1351,
+27
View File
@@ -38,6 +38,7 @@ import {ControlValueAccessor, NG_VALUE_ACCESSOR} from './control_value_accessor'
import {NG_CONTROL_INTEGRATION_PROVIDER, NgControl} from './ng_control';
import {NgForm} from './ng_form';
import {NgModelGroup} from './ng_model_group';
import {FormGroupDirective} from './reactive_directives/form_group_directive';
import {
CALL_SET_DISABLED_STATE,
controlPath,
@@ -49,6 +50,7 @@ import {
formGroupNameException,
missingNameException,
modelParentException,
ngModelInChildComponentWarning,
} from './template_driven_errors';
import {AsyncValidator, AsyncValidatorFn, Validator, ValidatorFn} from './validators';
@@ -182,6 +184,8 @@ export class NgModel extends NgControl implements OnChanges, OnDestroy {
/** @internal */
_registered = false;
private _ngModelInjector?: Injector;
/**
* Internal reference to the view model value.
* @docs-private
@@ -249,12 +253,35 @@ export class NgModel extends NgControl implements OnChanges, OnDestroy {
) {
super(injector, renderer, valueAccessors);
this._parent = parent;
if (typeof ngDevMode === 'undefined' || ngDevMode) {
this._ngModelInjector = injector;
}
this._setValidators(validators);
this._setAsyncValidators(asyncValidators);
}
/** @docs-private */
ngOnChanges(changes: SimpleChanges) {
if (
!this._registered &&
(typeof ngDevMode === 'undefined' || ngDevMode) &&
this._parent === null &&
!this.options?.standalone
) {
const parentContainer = this._ngModelInjector?.get(ControlContainer, null);
if (parentContainer != null) {
const typeName =
parentContainer instanceof NgForm
? 'NgForm'
: parentContainer instanceof FormGroupDirective
? 'FormGroupDirective'
: parentContainer instanceof NgModelGroup
? 'NgModelGroup'
: parentContainer.constructor.name || 'ControlContainer';
console.warn(ngModelInChildComponentWarning(typeName));
}
}
this._checkForErrors();
if (!this._registered || 'name' in changes) {
if (this._registered) {
@@ -6,7 +6,10 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {ɵRuntimeError as RuntimeError} from '@angular/core';
import {
ɵformatRuntimeError as formatRuntimeError,
ɵRuntimeError as RuntimeError,
} from '@angular/core';
import {RuntimeErrorCode} from '../errors';
@@ -50,6 +53,17 @@ export function formGroupNameException(): Error {
);
}
export function ngModelInChildComponentWarning(containerTypeName: string): string {
return formatRuntimeError(
RuntimeErrorCode.NGMODEL_IN_CHILD_COMPONENT,
`ngModel on a form control inside a child component cannot register with the ${containerTypeName} in the ` +
`parent component because @Host() stops injection at the component boundary. ` +
`To register this control with the parent form, add viewProviders to the child component: ` +
`@Component({ ..., viewProviders: [{ provide: ControlContainer, useExisting: ${containerTypeName} }] }). ` +
`Or, to opt out of form registration, use [ngModelOptions]="{standalone: true}".`,
);
}
export function missingNameException(): Error {
return new RuntimeError(
RuntimeErrorCode.NGMODEL_WITHOUT_NAME,
+1
View File
@@ -37,4 +37,5 @@ export const enum RuntimeErrorCode {
NGMODEL_IN_FORM_GROUP_NAME = 1351,
NGMODEL_WITHOUT_NAME = 1352,
NGMODELGROUP_IN_FORM_GROUP = 1353,
NGMODEL_IN_CHILD_COMPONENT = -1354,
}
@@ -25,8 +25,10 @@ import {
AbstractControl,
AsyncValidator,
COMPOSITION_BUFFER_MODE,
ControlContainer,
ControlValueAccessor,
FormControl,
FormGroup,
FormsModule,
MaxValidator,
MinValidator,
@@ -35,6 +37,7 @@ import {
NG_VALUE_ACCESSOR,
NgForm,
NgModel,
ReactiveFormsModule,
Validator,
} from '../index';
@@ -2774,6 +2777,89 @@ describe('template-driven forms integration tests', () => {
await timeout();
expect(() => fixture.detectChanges()).not.toThrowError();
});
describe('cross-component boundary warning', () => {
it('should warn when ngModel in a child component cannot reach parent NgForm via @Host()', () => {
const warnSpy = spyOn(console, 'warn');
const fixture = initTest(NgModelCrossComponentParent, NgModelCrossComponentChild);
fixture.detectChanges();
expect(warnSpy).toHaveBeenCalledWith(jasmine.stringContaining('NgForm'));
expect(warnSpy).toHaveBeenCalledWith(jasmine.stringContaining('viewProviders'));
});
it('should warn with FormGroupDirective name when ngModel cannot reach parent FormGroupDirective via @Host()', () => {
const warnSpy = spyOn(console, 'warn');
TestBed.configureTestingModule({
declarations: [NgModelCrossComponentFormGroupParent, NgModelCrossComponentFormGroupChild],
imports: [FormsModule, ReactiveFormsModule, CommonModule],
});
const fixture = TestBed.createComponent(NgModelCrossComponentFormGroupParent);
fixture.detectChanges();
expect(warnSpy).toHaveBeenCalledWith(jasmine.stringContaining('FormGroupDirective'));
expect(warnSpy).toHaveBeenCalledWith(jasmine.stringContaining('viewProviders'));
});
it('should warn with NgModelGroup name when ngModel cannot reach parent NgModelGroup via @Host()', () => {
const warnSpy = spyOn(console, 'warn');
const fixture = initTest(NgModelCrossComponentGroupParent, NgModelCrossComponentGroupChild);
fixture.detectChanges();
expect(warnSpy).toHaveBeenCalledWith(jasmine.stringContaining('NgModelGroup'));
expect(warnSpy).not.toHaveBeenCalledWith(
jasmine.stringContaining('useExisting: ControlContainer'),
);
});
it('should not warn when ngModel is in the same component as NgForm', async () => {
const warnSpy = spyOn(console, 'warn');
const fixture = initTest(NgModelForm);
fixture.detectChanges();
await timeout();
expect(warnSpy).not.toHaveBeenCalledWith(jasmine.stringContaining('viewProviders'));
});
it('should not warn when child component uses viewProviders to bridge ControlContainer', () => {
const warnSpy = spyOn(console, 'warn');
const fixture = initTest(
NgModelCrossComponentParentWithViewProviders,
NgModelCrossComponentChildWithViewProviders,
);
fixture.detectChanges();
expect(warnSpy).not.toHaveBeenCalledWith(jasmine.stringContaining('viewProviders'));
});
it('should not warn when ngModel is standalone with no parent form', async () => {
const warnSpy = spyOn(console, 'warn');
const fixture = initTest(StandaloneNgModel);
fixture.detectChanges();
await timeout();
expect(warnSpy).not.toHaveBeenCalledWith(jasmine.stringContaining('viewProviders'));
});
it('should not warn when ngModel in a child component uses [ngModelOptions]="{standalone: true}"', () => {
const warnSpy = spyOn(console, 'warn');
TestBed.configureTestingModule({
declarations: [
NgModelCrossComponentParentStandaloneOpt,
NgModelCrossComponentChildStandaloneOpt,
],
imports: [FormsModule],
});
const fixture = TestBed.createComponent(NgModelCrossComponentParentStandaloneOpt);
fixture.detectChanges();
expect(warnSpy).not.toHaveBeenCalledWith(jasmine.stringContaining('viewProviders'));
});
it('should not warn when ngModel inside a ControlValueAccessor uses [ngModelOptions]="{standalone: true}"', () => {
const warnSpy = spyOn(console, 'warn');
TestBed.configureTestingModule({
declarations: [NgModelCvaHostParent, NgModelCvaWithInternalNgModel],
imports: [FormsModule],
});
const fixture = TestBed.createComponent(NgModelCvaHostParent);
fixture.detectChanges();
expect(warnSpy).not.toHaveBeenCalledWith(jasmine.stringContaining('viewProviders'));
});
});
});
});
@@ -3124,3 +3210,140 @@ class NgModelNoMinMaxValidator {
class NativeDialogForm {
@ViewChild('form') form!: ElementRef<HTMLFormElement>;
}
@Component({
selector: 'ng-model-cross-component-child',
template: `<input type="text" name="child" [(ngModel)]="value" />`,
standalone: false,
})
class NgModelCrossComponentChild {
value = '';
}
@Component({
selector: 'ng-model-cross-component-child-vp',
template: `<input type="text" name="child" [(ngModel)]="value" />`,
standalone: false,
viewProviders: [{provide: ControlContainer, useExisting: NgForm}],
})
class NgModelCrossComponentChildWithViewProviders {
value = '';
}
@Component({
selector: 'ng-model-cross-component-parent',
template: `
<form>
<input type="text" name="parent" [(ngModel)]="value" />
<ng-model-cross-component-child></ng-model-cross-component-child>
</form>
`,
standalone: false,
})
class NgModelCrossComponentParent {
value = '';
}
@Component({
selector: 'ng-model-cross-component-parent-vp',
template: `
<form>
<input type="text" name="parent" [(ngModel)]="value" />
<ng-model-cross-component-child-vp></ng-model-cross-component-child-vp>
</form>
`,
standalone: false,
})
class NgModelCrossComponentParentWithViewProviders {
value = '';
}
@Component({
selector: 'ng-model-cross-component-form-group-child',
template: `<input type="text" name="child" [(ngModel)]="value" />`,
standalone: false,
})
class NgModelCrossComponentFormGroupChild {
value = '';
}
@Component({
selector: 'ng-model-cross-component-form-group-parent',
template: `
<div [formGroup]="form">
<ng-model-cross-component-form-group-child></ng-model-cross-component-form-group-child>
</div>
`,
standalone: false,
})
class NgModelCrossComponentFormGroupParent {
form = new FormGroup({});
}
@Component({
selector: 'ng-model-cross-component-group-child',
template: `<input type="text" name="child" [(ngModel)]="value" />`,
standalone: false,
})
class NgModelCrossComponentGroupChild {
value = '';
}
@Component({
selector: 'ng-model-cross-component-group-parent',
template: `
<form>
<div ngModelGroup="group">
<ng-model-cross-component-group-child></ng-model-cross-component-group-child>
</div>
</form>
`,
standalone: false,
})
class NgModelCrossComponentGroupParent {}
@Component({
selector: 'ng-model-cross-component-child-standalone-opt',
template: `<input type="text" [ngModelOptions]="{standalone: true}" ngModel />`,
standalone: false,
})
class NgModelCrossComponentChildStandaloneOpt {}
@Component({
selector: 'ng-model-cross-component-parent-standalone-opt',
template: `
<form>
<ng-model-cross-component-child-standalone-opt></ng-model-cross-component-child-standalone-opt>
</form>
`,
standalone: false,
})
class NgModelCrossComponentParentStandaloneOpt {}
@Component({
selector: 'ng-model-cva-with-internal-ng-model',
template: `<input type="text" [(ngModel)]="internal" [ngModelOptions]="{standalone: true}" />`,
standalone: false,
providers: [
{provide: NG_VALUE_ACCESSOR, useExisting: NgModelCvaWithInternalNgModel, multi: true},
],
})
class NgModelCvaWithInternalNgModel implements ControlValueAccessor {
internal = '';
writeValue(val: any) {
this.internal = val;
}
registerOnChange(_fn: any) {}
registerOnTouched(_fn: any) {}
}
@Component({
selector: 'ng-model-cva-host-parent',
template: `
<form>
<ng-model-cva-with-internal-ng-model name="x" ngModel></ng-model-cva-with-internal-ng-model>
</form>
`,
standalone: false,
})
class NgModelCvaHostParent {}