mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user