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', () => {