From 42e1062bc5532a7c700790c1f175f12709faaa30 Mon Sep 17 00:00:00 2001 From: Leon Senft Date: Fri, 29 Aug 2025 15:00:36 -0700 Subject: [PATCH] refactor(core): add specialized instruction for `[control]` bindings (#63773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Emit a `ɵɵcontrol` instruction in place of `ɵɵproperty` for property bindings named "control". This instruction cannot be chained, but is otherwise functionally equivalent. * Upcoming changes will use the `ɵɵcontrol` instruction to bind a signal form field to a UI control (be it a native element or custom directive). PR Close #63773 --- .../control_bindings/GOLDEN_PARTIAL.js | 50 +++++++++++++++++++ .../control_bindings/TEST_CASES.json | 18 +++++++ .../control_bindings/control_bindings.js | 17 +++++++ .../control_bindings/control_bindings.ts | 16 ++++++ .../compiler/src/render3/r3_identifiers.ts | 1 + .../compiler/src/render3/view/compiler.ts | 2 +- .../src/template/pipeline/ir/src/enums.ts | 5 ++ .../template/pipeline/ir/src/expression.ts | 1 + .../template/pipeline/ir/src/ops/update.ts | 30 ++++++++++- .../src/template/pipeline/src/instruction.ts | 17 +++++++ .../src/phases/attribute_extraction.ts | 16 ++++++ .../src/phases/binding_specialization.ts | 2 + .../src/template/pipeline/src/phases/reify.ts | 7 +++ .../pipeline/src/phases/var_counting.ts | 1 + .../core/src/core_render3_private_export.ts | 1 + packages/core/src/render3/index.ts | 1 + packages/core/src/render3/instructions/all.ts | 1 + .../core/src/render3/instructions/control.ts | 40 +++++++++++++++ packages/core/src/render3/jit/environment.ts | 1 + .../test/acceptance/property_binding_spec.ts | 30 +++++++++++ 20 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/GOLDEN_PARTIAL.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/TEST_CASES.json create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.ts create mode 100644 packages/core/src/render3/instructions/control.ts diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/GOLDEN_PARTIAL.js new file mode 100644 index 00000000000..a31f34f9084 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/GOLDEN_PARTIAL.js @@ -0,0 +1,50 @@ +/**************************************************************************************************** + * PARTIAL FILE: control_bindings.js + ****************************************************************************************************/ +import { Component, Directive, input } from '@angular/core'; +import * as i0 from "@angular/core"; +export class Control { + constructor() { + this.control = input(...(ngDevMode ? [undefined, { debugName: "control" }] : [])); + } +} +Control.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: Control, deps: [], target: i0.ɵɵFactoryTarget.Directive }); +Control.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "0.0.0-PLACEHOLDER", type: Control, isStandalone: true, selector: "[control]", inputs: { control: { classPropertyName: "control", publicName: "control", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: Control, decorators: [{ + type: Directive, + args: [{ selector: '[control]' }] + }] }); +export class MyComponent { + constructor() { + this.value = 'Hello, world!'; + } +} +MyComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); +MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: ` + + `, isInline: true, dependencies: [{ kind: "directive", type: Control, selector: "[control]", inputs: ["control"] }] }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, decorators: [{ + type: Component, + args: [{ + template: ` + + `, + imports: [Control], + }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: control_bindings.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class Control { + readonly control: import("@angular/core").InputSignal; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +export declare class MyComponent { + value: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} + diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/TEST_CASES.json new file mode 100644 index 00000000000..6e0ada3a2e3 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/TEST_CASES.json @@ -0,0 +1,18 @@ +{ + "$schema": "../../test_case_schema.json", + "cases": [ + { + "description": "should generate control instruction for `control` property bindings", + "inputFiles": [ + "control_bindings.ts" + ], + "expectations": [ + { + "files": [ + "control_bindings.js" + ] + } + ] + } + ] +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js new file mode 100644 index 00000000000..0144a3be488 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js @@ -0,0 +1,17 @@ +MyComponent.ɵcmp = /* @__PURE__ */i0.ɵɵdefineComponent({ + type: MyComponent, + selectors: [["ng-component"]], + decls: 1, + vars: 1, + consts: [[3, "control"]], + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵelement(0, "input", 0); + } + if (rf & 2) { + i0.ɵɵcontrol(ctx.value); + } + }, + dependencies: [Control], + encapsulation: 2 +}); \ No newline at end of file diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.ts new file mode 100644 index 00000000000..356e247c182 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.ts @@ -0,0 +1,16 @@ +import {Component, Directive, input} from '@angular/core'; + +@Directive({selector: '[control]'}) +export class Control { + readonly control = input(); +} + +@Component({ + template: ` + + `, + imports: [Control], +}) +export class MyComponent { + value = 'Hello, world!'; +} diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index 3c48d64de48..894bf60128f 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -254,6 +254,7 @@ export class Identifiers { static ariaProperty: o.ExternalReference = {name: 'ɵɵariaProperty', moduleName: CORE}; static property: o.ExternalReference = {name: 'ɵɵproperty', moduleName: CORE}; + static control: o.ExternalReference = {name: 'ɵɵcontrol', moduleName: CORE}; static animationEnterListener: o.ExternalReference = { name: 'ɵɵanimateEnterListener', diff --git a/packages/compiler/src/render3/view/compiler.ts b/packages/compiler/src/render3/view/compiler.ts index 4b3ab6c3124..bea1037adfe 100644 --- a/packages/compiler/src/render3/view/compiler.ts +++ b/packages/compiler/src/render3/view/compiler.ts @@ -248,7 +248,7 @@ export function compileComponentFromMetadata( getTemplateSourceLocationsEnabled(), ); - // Then the IR is transformed to prepare it for cod egeneration. + // Then the IR is transformed to prepare it for code generation. transform(tpl, CompilationJobKind.Tmpl); // Finally we emit the template function: diff --git a/packages/compiler/src/template/pipeline/ir/src/enums.ts b/packages/compiler/src/template/pipeline/ir/src/enums.ts index 08a7ea01fe2..ab60ccc7109 100644 --- a/packages/compiler/src/template/pipeline/ir/src/enums.ts +++ b/packages/compiler/src/template/pipeline/ir/src/enums.ts @@ -299,6 +299,11 @@ export enum OpKind { * An operation to bind animation events to an element. */ AnimationListener, + + /** + * An operation to bind a signal form field to a control directive. + */ + Control, } /** diff --git a/packages/compiler/src/template/pipeline/ir/src/expression.ts b/packages/compiler/src/template/pipeline/ir/src/expression.ts index 894334cb86c..e6b099e3a0b 100644 --- a/packages/compiler/src/template/pipeline/ir/src/expression.ts +++ b/packages/compiler/src/template/pipeline/ir/src/expression.ts @@ -1103,6 +1103,7 @@ export function transformExpressionsInOp( case OpKind.Property: case OpKind.DomProperty: case OpKind.Attribute: + case OpKind.Control: if (op.expression instanceof Interpolation) { transformExpressionsInInterpolation(op.expression, transform, flags); } else { diff --git a/packages/compiler/src/template/pipeline/ir/src/ops/update.ts b/packages/compiler/src/template/pipeline/ir/src/ops/update.ts index 070c94ad008..4c97a268e9e 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/update.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/update.ts @@ -56,7 +56,8 @@ export type UpdateOp = | RepeaterOp | DeferWhenOp | AnimationBindingOp - | StoreLetOp; + | StoreLetOp + | ControlOp; /** * A logical operation to perform string interpolation on a text node. @@ -1062,3 +1063,30 @@ export function createStoreLetOp( ...NEW_OP, }; } + +/** + * A specialized {@link PropertyOp} that may bind a form field to a control. + */ +export interface ControlOp extends Omit { + kind: OpKind.Control; +} + +/** Creates a {@link ControlOp}. */ +export function createControlOp(op: BindingOp): ControlOp { + return { + kind: OpKind.Control, + target: op.target, + expression: op.expression, + bindingKind: op.bindingKind, + securityContext: op.securityContext, + sanitizer: null, + isStructuralTemplateAttribute: op.isStructuralTemplateAttribute, + templateKind: op.templateKind, + i18nContext: op.i18nContext, + i18nMessage: op.i18nMessage, + sourceSpan: op.sourceSpan, + ...TRAIT_DEPENDS_ON_SLOT_CONTEXT, + ...TRAIT_CONSUMES_VARS, + ...NEW_OP, + }; +} diff --git a/packages/compiler/src/template/pipeline/src/instruction.ts b/packages/compiler/src/template/pipeline/src/instruction.ts index 1d9a0b72844..818da8799e1 100644 --- a/packages/compiler/src/template/pipeline/src/instruction.ts +++ b/packages/compiler/src/template/pipeline/src/instruction.ts @@ -599,6 +599,23 @@ export function property( return propertyBase(Identifiers.property, name, expression, sanitizer, sourceSpan); } +export function control( + expression: o.Expression | ir.Interpolation, + sanitizer: o.Expression | null, + sourceSpan: ParseSourceSpan, +): ir.UpdateOp { + const args = []; + if (expression instanceof ir.Interpolation) { + args.push(interpolationToExpression(expression, sourceSpan)); + } else { + args.push(expression); + } + if (sanitizer !== null) { + args.push(sanitizer); + } + return call(Identifiers.control, args, sourceSpan); +} + export function twoWayProperty( name: string, expression: o.Expression, diff --git a/packages/compiler/src/template/pipeline/src/phases/attribute_extraction.ts b/packages/compiler/src/template/pipeline/src/phases/attribute_extraction.ts index 671a09becf9..c42efabc799 100644 --- a/packages/compiler/src/template/pipeline/src/phases/attribute_extraction.ts +++ b/packages/compiler/src/template/pipeline/src/phases/attribute_extraction.ts @@ -55,6 +55,22 @@ export function extractAttributes(job: CompilationJob): void { ); } break; + case ir.OpKind.Control: + ir.OpList.insertBefore( + // Deliberately null i18nMessage value + ir.createExtractedAttributeOp( + op.target, + ir.BindingKind.Property, + null, + 'control', + /* expression */ null, + /* i18nContext */ null, + /* i18nMessage */ null, + op.securityContext, + ), + lookupElement(elements, op.target), + ); + break; case ir.OpKind.TwoWayProperty: ir.OpList.insertBefore( ir.createExtractedAttributeOp( diff --git a/packages/compiler/src/template/pipeline/src/phases/binding_specialization.ts b/packages/compiler/src/template/pipeline/src/phases/binding_specialization.ts index 5348e146a74..76009d20b08 100644 --- a/packages/compiler/src/template/pipeline/src/phases/binding_specialization.ts +++ b/packages/compiler/src/template/pipeline/src/phases/binding_specialization.ts @@ -127,6 +127,8 @@ export function specializeBindings(job: CompilationJob): void { op.sourceSpan, ), ); + } else if (op.name === 'control') { + ir.OpList.replace(op, ir.createControlOp(op)); } else { ir.OpList.replace( op, diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index dead4e4f9c1..54ef04dc962 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -600,6 +600,9 @@ function reifyUpdateOperations(unit: CompilationUnit, ops: ir.OpList(value: T, sanitizer?: SanitizerFn | null): void { + const lView = getLView(); + const bindingIndex = nextBindingIndex(); + if (bindingUpdated(lView, bindingIndex, value)) { + const tView = getTView(); + const tNode = getSelectedTNode(); + setPropertyAndInputs(tNode, lView, 'control', value, lView[RENDERER], sanitizer); + ngDevMode && storePropertyBindingMetadata(tView.data, tNode, 'control', bindingIndex); + } +} diff --git a/packages/core/src/render3/jit/environment.ts b/packages/core/src/render3/jit/environment.ts index 7de24cb545a..74a57483518 100644 --- a/packages/core/src/render3/jit/environment.ts +++ b/packages/core/src/render3/jit/environment.ts @@ -90,6 +90,7 @@ export const angularCoreEnv: {[name: string]: unknown} = (() => ({ 'ɵɵdomProperty': r3.ɵɵdomProperty, 'ɵɵariaProperty': r3.ɵɵariaProperty, 'ɵɵproperty': r3.ɵɵproperty, + 'ɵɵcontrol': r3.ɵɵcontrol, 'ɵɵpipe': r3.ɵɵpipe, 'ɵɵqueryRefresh': r3.ɵɵqueryRefresh, 'ɵɵqueryAdvance': r3.ɵɵqueryAdvance, diff --git a/packages/core/test/acceptance/property_binding_spec.ts b/packages/core/test/acceptance/property_binding_spec.ts index 48d9bb92563..dc7189c7045 100644 --- a/packages/core/test/acceptance/property_binding_spec.ts +++ b/packages/core/test/acceptance/property_binding_spec.ts @@ -610,6 +610,36 @@ describe('property bindings', () => { expect(idDir.idNumber).toBe('four'); expect(otherDir.id).toBe(3); }); + + it('should support input bindings named "control"', () => { + // Angular has specialized support for binding to form controls (e.g. `[control]="field"`). + // This test ensures that `[control]` property bindings can still target other inputs bearing + // the same name. + + @Directive({selector: '[control]'}) + class Control { + @Input() control = 'Default control value'; + } + + @Component({ + template: ` +
+ `, + imports: [Control], + }) + class App { + value?: string; + } + + const fixture = TestBed.createComponent(App); + const control = fixture.debugElement.query(By.directive(Control)).injector.get(Control); + expect(control.control).toBe('Default control value'); + + fixture.componentInstance.value = 'Bound control value'; + fixture.detectChanges(); + + expect(control.control).toBe('Bound control value'); + }); }); describe('attributes and input properties', () => {