fix(core): control not recognized when input has directive injecting ViewContainerRef (#64368)

When a directive injects a `ViewContainerRef`, the runtime inserts a container that was throwing off the logic that recognizes native controls.

These changes switch to check if the node is a native control through the `TNode`. This also makes it a bit less prone to breaking during SSR.

Fixes #64362.

PR Close #64368
This commit is contained in:
Kristiyan Kostadinov
2025-10-13 18:12:42 -04:00
committed by Andrew Kushnir
parent 356dc4c952
commit ab415f3d7f
2 changed files with 75 additions and 25 deletions
@@ -10,12 +10,13 @@ import {bindingUpdated} from '../bindings';
import {ɵCONTROL, ɵControl} from '../interfaces/control';
import {ComponentDef} from '../interfaces/definition';
import {InputFlags} from '../interfaces/input_flags';
import {TNode, TNodeFlags} from '../interfaces/node';
import {TElementNode, TNode, TNodeFlags, TNodeType} from '../interfaces/node';
import {Renderer} from '../interfaces/renderer';
import {SanitizerFn} from '../interfaces/sanitization';
import {isComponentHost} from '../interfaces/type_checks';
import {LView, RENDERER, TView} from '../interfaces/view';
import {getCurrentTNode, getLView, getSelectedTNode, getTView, nextBindingIndex} from '../state';
import {isNameOnlyAttributeMarker} from '../util/attrs_utils';
import {getNativeByTNode} from '../util/view_utils';
import {listenToOutput} from '../view/directive_outputs';
import {listenToDomEvent, wrapListener} from '../view/listeners';
@@ -131,12 +132,11 @@ function getControlDirectiveFirstCreatePass<T>(
}
}
const nativeElement = lView[tNode.index];
if (isNativeControl(nativeElement)) {
if (isNumericInput(nativeElement)) {
if (isNativeControl(tNode)) {
if (isNumericInput(tNode)) {
tNode.flags |= TNodeFlags.isNativeNumericControl;
}
if (isTextControl(nativeElement)) {
if (isTextControl(tNode)) {
tNode.flags |= TNodeFlags.isNativeTextControl;
}
return control;
@@ -259,12 +259,12 @@ interface HTMLTextAreaElementNarrowed extends HTMLTextAreaElement {
*/
type NativeControlElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElementNarrowed;
function isNativeControl(element: unknown): element is NativeControlElement {
return (
element instanceof HTMLInputElement ||
element instanceof HTMLSelectElement ||
element instanceof HTMLTextAreaElement
);
function isNativeControl(tNode: TNode): tNode is TElementNode {
if (tNode.type !== TNodeType.Element) {
return false;
}
const tagName = tNode.value;
return tagName === 'input' || tagName === 'textarea' || tagName === 'select';
}
/**
@@ -402,17 +402,33 @@ function isDateOrNull(value: unknown): value is Date | null {
}
/** Returns whether `control` has a numeric input type. */
function isNumericInput(control: NativeControlElement) {
switch (control.type) {
case 'date':
case 'datetime-local':
case 'month':
case 'number':
case 'range':
case 'time':
case 'week':
return true;
function isNumericInput(tNode: TElementNode): boolean {
if (!tNode.attrs || tNode.value !== 'input') {
return false;
}
for (let i = 0; i < tNode.attrs.length; i += 2) {
const name = tNode.attrs[i];
if (isNameOnlyAttributeMarker(name)) {
break;
}
if (name === 'type') {
const value = tNode.attrs[i + 1];
return (
value === 'date' ||
value === 'datetime-local' ||
value === 'month' ||
value === 'number' ||
value === 'range' ||
value === 'time' ||
value === 'week'
);
}
}
return false;
}
@@ -422,10 +438,8 @@ function isNumericInput(control: NativeControlElement) {
* This is not the same as an input with `type="text"`, but rather any input that accepts
* text-based input which includes numeric types.
*/
function isTextControl(
control: NativeControlElement,
): control is Exclude<NativeControlElement, HTMLSelectElement> {
return !(control instanceof HTMLSelectElement);
function isTextControl(tNode: TElementNode): boolean {
return tNode.value !== 'select';
}
/**
@@ -8,7 +8,9 @@
import {
Component,
Directive,
ElementRef,
inject,
Injector,
input,
inputBinding,
@@ -17,6 +19,7 @@ import {
signal,
viewChild,
viewChildren,
ViewContainerRef,
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {
@@ -1079,6 +1082,39 @@ describe('field directive', () => {
).toEqual(['manual disabled', 'schema disabled']);
});
it('should bind to native control that has directive injecting ViewContainerRef', () => {
@Directive({selector: 'input'})
class InputDirective {
vcr = inject(ViewContainerRef);
}
@Component({
imports: [Field, InputDirective],
template: `<input [field]="f">`,
})
class TestCmp {
f = form(signal('test'));
}
const fix = act(() => TestBed.createComponent(TestCmp));
const input = fix.nativeElement.firstChild as HTMLInputElement;
const cmp = fix.componentInstance as TestCmp;
// Initial state
expect(input.value).toBe('test');
// Model -> View
act(() => cmp.f().value.set('testing'));
expect(input.value).toBe('testing');
// View -> Model
act(() => {
input.value = 'typing';
input.dispatchEvent(new Event('input'));
});
expect(cmp.f().value()).toBe('typing');
});
describe('should work with different input types', () => {
it('should sync string field with number type input', () => {
@Component({