Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions adev/src/content/reference/errors/NG01354.md
Original file line number Diff line number Diff line change
@@ -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]`):

```typescript
// 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 = '';
}
```

```typescript
// 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 changes: 2 additions & 0 deletions goldens/public-api/forms/errors.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions packages/forms/src/directives/ng_model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -49,6 +50,7 @@ import {
formGroupNameException,
missingNameException,
modelParentException,
ngModelInChildComponentWarning,
} from './template_driven_errors';
import {AsyncValidator, AsyncValidatorFn, Validator, ValidatorFn} from './validators';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 15 additions & 1 deletion packages/forms/src/directives/template_driven_errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/forms/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
191 changes: 191 additions & 0 deletions packages/forms/test/template_integration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ import {
AbstractControl,
AsyncValidator,
COMPOSITION_BUFFER_MODE,
ControlContainer,
ControlValueAccessor,
FormControl,
FormGroup,
FormsModule,
MaxValidator,
MinValidator,
Expand All @@ -35,6 +37,7 @@ import {
NG_VALUE_ACCESSOR,
NgForm,
NgModel,
ReactiveFormsModule,
Validator,
} from '../index';

Expand Down Expand Up @@ -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'));
});
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if we don't have a NgForm as parent but a FormGroup ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid concert, updated the code.

});
});

Expand Down Expand Up @@ -3124,3 +3200,118 @@ 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-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 {}
Loading