From ab415f3d7f23cef8e00595e9cf6af2c8b764a8ae Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 13 Oct 2025 18:12:42 -0400 Subject: [PATCH] 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 --- .../core/src/render3/instructions/control.ts | 64 +++++++++++-------- .../signals/test/web/field_directive.spec.ts | 36 +++++++++++ 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/packages/core/src/render3/instructions/control.ts b/packages/core/src/render3/instructions/control.ts index 0eaa77352f5..352155c4786 100644 --- a/packages/core/src/render3/instructions/control.ts +++ b/packages/core/src/render3/instructions/control.ts @@ -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( } } - 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 { - return !(control instanceof HTMLSelectElement); +function isTextControl(tNode: TElementNode): boolean { + return tNode.value !== 'select'; } /** diff --git a/packages/forms/signals/test/web/field_directive.spec.ts b/packages/forms/signals/test/web/field_directive.spec.ts index 13eec7d95dd..e6049cca9b7 100644 --- a/packages/forms/signals/test/web/field_directive.spec.ts +++ b/packages/forms/signals/test/web/field_directive.spec.ts @@ -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: ``, + }) + 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({