refactor(core): add specialized instruction for [control] bindings (#63773)

* 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
This commit is contained in:
Leon Senft
2025-08-29 15:00:36 -07:00
committed by Miles Malerba
parent 548ea027f6
commit 42e1062bc5
20 changed files with 255 additions and 2 deletions
@@ -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: `
<input [control]="value">
`, 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: `
<input [control]="value">
`,
imports: [Control],
}]
}] });
/****************************************************************************************************
* PARTIAL FILE: control_bindings.d.ts
****************************************************************************************************/
import * as i0 from "@angular/core";
export declare class Control {
readonly control: import("@angular/core").InputSignal<string | undefined>;
static ɵfac: i0.ɵɵFactoryDeclaration<Control, never>;
static ɵdir: i0.ɵɵDirectiveDeclaration<Control, "[control]", never, { "control": { "alias": "control"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
}
export declare class MyComponent {
value: string;
static ɵfac: i0.ɵɵFactoryDeclaration<MyComponent, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyComponent, "ng-component", never, {}, {}, never, never, true, never>;
}
@@ -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"
]
}
]
}
]
}
@@ -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
});
@@ -0,0 +1,16 @@
import {Component, Directive, input} from '@angular/core';
@Directive({selector: '[control]'})
export class Control {
readonly control = input<string>();
}
@Component({
template: `
<input [control]="value">
`,
imports: [Control],
})
export class MyComponent {
value = 'Hello, world!';
}
@@ -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',
@@ -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:
@@ -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,
}
/**
@@ -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 {
@@ -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<PropertyOp, 'kind' | 'name'> {
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,
};
}
@@ -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,
@@ -55,6 +55,22 @@ export function extractAttributes(job: CompilationJob): void {
);
}
break;
case ir.OpKind.Control:
ir.OpList.insertBefore<ir.CreateOp>(
// 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.CreateOp>(
ir.createExtractedAttributeOp(
@@ -127,6 +127,8 @@ export function specializeBindings(job: CompilationJob): void {
op.sourceSpan,
),
);
} else if (op.name === 'control') {
ir.OpList.replace<ir.UpdateOp>(op, ir.createControlOp(op));
} else {
ir.OpList.replace<ir.UpdateOp>(
op,
@@ -600,6 +600,9 @@ function reifyUpdateOperations(unit: CompilationUnit, ops: ir.OpList<ir.UpdateOp
: reifyProperty(op),
);
break;
case ir.OpKind.Control:
ir.OpList.replace(op, reifyControl(op));
break;
case ir.OpKind.TwoWayProperty:
ir.OpList.replace(
op,
@@ -719,6 +722,10 @@ function reifyProperty(op: ir.PropertyOp): ir.UpdateOp {
: ng.property(op.name, op.expression, op.sanitizer, op.sourceSpan);
}
function reifyControl(op: ir.ControlOp): ir.UpdateOp {
return ng.control(op.expression, op.sanitizer, op.sourceSpan);
}
function reifyIrExpression(expr: o.Expression): o.Expression {
if (!ir.isIrExpression(expr)) {
return expr;
@@ -119,6 +119,7 @@ function varsUsedByOp(op: (ir.CreateOp | ir.UpdateOp) & ir.ConsumesVarsTrait): n
return slots;
case ir.OpKind.Property:
case ir.OpKind.DomProperty:
case ir.OpKind.Control:
slots = 1;
// We need to assign a slot even for singleton interpolations, because the
@@ -93,6 +93,7 @@ export {
ɵɵconditionalBranchCreate,
ɵɵcontentQuery,
ɵɵcontentQuerySignal,
ɵɵcontrol,
ɵɵcomponentInstance,
ɵɵCopyDefinitionFeature,
ɵɵdefineComponent,
+1
View File
@@ -95,6 +95,7 @@ export {
ɵɵprojection,
ɵɵprojectionDef,
ɵɵproperty,
ɵɵcontrol,
ɵɵcontentQuery,
ɵɵcontentQuerySignal,
ɵɵloadQuery,
@@ -32,6 +32,7 @@ export * from './attribute';
export * from './animation';
export * from './change_detection';
export * from './component_instance';
export * from './control';
export * from './control_flow';
export * from './di';
export * from './di_attr';
@@ -0,0 +1,40 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {bindingUpdated} from '../bindings';
import {SanitizerFn} from '../interfaces/sanitization';
import {RENDERER} from '../interfaces/view';
import {getLView, getSelectedTNode, getTView, nextBindingIndex} from '../state';
import {setPropertyAndInputs, storePropertyBindingMetadata} from './shared';
/**
* Update a `control` property on a selected element.
*
* Operates on the element selected by index via the {@link select} instruction.
*
* If the property name also exists as an input property on one of the element's directives,
* the component property will be set instead of the element property. This check must
* be conducted at runtime so child components that add new `@Inputs` don't have to be re-compiled
*
* @param propName Name of property. Because it is going to DOM, this is not subject to
* renaming as part of minification.
* @param value New value to write.
* @param sanitizer An optional function used to sanitize the value.
*
* @codeGenApi
*/
export function ɵɵcontrol<T>(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);
}
}
@@ -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,
@@ -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: `
<div [control]="value"></div>
`,
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', () => {