diff --git a/adev/src/content/reference/errors/NG01354.md b/adev/src/content/reference/errors/NG01354.md new file mode 100644 index 000000000000..46152d1db4ae --- /dev/null +++ b/adev/src/content/reference/errors/NG01354.md @@ -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 (`
`), or +`FormGroupDirective` for reactive forms (`[formGroup]`): + +```typescript +// Parent uses a template-driven form: ... +import {ControlContainer, NgForm} from '@angular/forms'; + +@Component({ + selector: 'app-child', + template: ``, + viewProviders: [{provide: ControlContainer, useExisting: NgForm}], +}) +export class Child { + email = ''; +} +``` + +```typescript +// Parent uses a reactive form:
...
+import {ControlContainer, FormGroupDirective} from '@angular/forms'; + +@Component({ + selector: 'app-child', + template: ``, + 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 + +``` + +## 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 `
` tag is the same +component that owns the `ngModel` input — if they differ, apply one of the options above. diff --git a/goldens/public-api/forms/errors.api.md b/goldens/public-api/forms/errors.api.md index a4d711f8cd5c..0ce21e10c564 100644 --- a/goldens/public-api/forms/errors.api.md +++ b/goldens/public-api/forms/errors.api.md @@ -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, diff --git a/packages/forms/src/directives/ng_model.ts b/packages/forms/src/directives/ng_model.ts index 755dd34080e5..1f95f3930a83 100644 --- a/packages/forms/src/directives/ng_model.ts +++ b/packages/forms/src/directives/ng_model.ts @@ -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,33 @@ 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' + : 'ControlContainer'; + console.warn(ngModelInChildComponentWarning(typeName)); + } + } this._checkForErrors(); if (!this._registered || 'name' in changes) { if (this._registered) { diff --git a/packages/forms/src/directives/template_driven_errors.ts b/packages/forms/src/directives/template_driven_errors.ts index d423793761d3..71834f1a377b 100644 --- a/packages/forms/src/directives/template_driven_errors.ts +++ b/packages/forms/src/directives/template_driven_errors.ts @@ -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, diff --git a/packages/forms/src/errors.ts b/packages/forms/src/errors.ts index a95d3503e12e..35ab1d899fca 100644 --- a/packages/forms/src/errors.ts +++ b/packages/forms/src/errors.ts @@ -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, } diff --git a/packages/forms/test/template_integration_spec.ts b/packages/forms/test/template_integration_spec.ts index 29a137ddd876..4e847c88572a 100644 --- a/packages/forms/test/template_integration_spec.ts +++ b/packages/forms/test/template_integration_spec.ts @@ -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,79 @@ 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 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 +3200,118 @@ class NgModelNoMinMaxValidator { class NativeDialogForm { @ViewChild('form') form!: ElementRef; } + +@Component({ + selector: 'ng-model-cross-component-child', + template: ``, + standalone: false, +}) +class NgModelCrossComponentChild { + value = ''; +} + +@Component({ + selector: 'ng-model-cross-component-child-vp', + template: ``, + standalone: false, + viewProviders: [{provide: ControlContainer, useExisting: NgForm}], +}) +class NgModelCrossComponentChildWithViewProviders { + value = ''; +} + +@Component({ + selector: 'ng-model-cross-component-parent', + template: ` + + + + + `, + standalone: false, +}) +class NgModelCrossComponentParent { + value = ''; +} + +@Component({ + selector: 'ng-model-cross-component-parent-vp', + template: ` +
+ + +
+ `, + standalone: false, +}) +class NgModelCrossComponentParentWithViewProviders { + value = ''; +} + +@Component({ + selector: 'ng-model-cross-component-form-group-child', + template: ``, + standalone: false, +}) +class NgModelCrossComponentFormGroupChild { + value = ''; +} + +@Component({ + selector: 'ng-model-cross-component-form-group-parent', + template: ` +
+ +
+ `, + standalone: false, +}) +class NgModelCrossComponentFormGroupParent { + form = new FormGroup({}); +} + +@Component({ + selector: 'ng-model-cross-component-child-standalone-opt', + template: ``, + standalone: false, +}) +class NgModelCrossComponentChildStandaloneOpt {} + +@Component({ + selector: 'ng-model-cross-component-parent-standalone-opt', + template: ` +
+ +
+ `, + standalone: false, +}) +class NgModelCrossComponentParentStandaloneOpt {} + +@Component({ + selector: 'ng-model-cva-with-internal-ng-model', + template: ``, + 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: ` +
+ +
+ `, + standalone: false, +}) +class NgModelCvaHostParent {}