From 0bf724f4d46292adfc728d6101566191d2c61074 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Fri, 30 Jun 2023 12:58:53 +0000 Subject: [PATCH] feat: support property interpolation in signal components --- .../property_bindings/GOLDEN_PARTIAL.js | 67 --------- .../property_bindings/TEST_CASES.json | 15 -- .../property_bindings/dom/GOLDEN_PARTIAL.js | 134 ++++++++++++++++++ .../property_bindings/dom/TEST_CASES.json | 25 ++++ .../simple_binding.js} | 0 .../simple_binding.ts} | 0 .../dom/with_interpolation.js | 27 ++++ .../dom/with_interpolation.ts | 27 ++++ .../compiler/src/render3/r3_identifiers.ts | 3 + .../src/template/pipeline/ir/src/enums.ts | 6 + .../template/pipeline/ir/src/expression.ts | 51 ++++++- .../src/template/pipeline/src/ingest.ts | 23 ++- .../src/template/pipeline/src/instruction.ts | 7 + .../src/template/pipeline/src/phases/reify.ts | 2 + packages/core/src/render3/jit/environment.ts | 1 + .../dom_property_bindings.spec.ts | 64 --------- .../dom_property_interpolation.spec.ts | 60 ++++++++ .../input_interpolation.spec.ts | 28 +++- .../core/test/signals-playground/index.ts | 14 +- 19 files changed, 392 insertions(+), 162 deletions(-) delete mode 100644 packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/GOLDEN_PARTIAL.js delete mode 100644 packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/TEST_CASES.json create mode 100644 packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/GOLDEN_PARTIAL.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/TEST_CASES.json rename packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/{simple_dom_binding.js => dom/simple_binding.js} (100%) rename packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/{simple_dom_binding.ts => dom/simple_binding.ts} (100%) create mode 100644 packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/with_interpolation.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/with_interpolation.ts create mode 100644 packages/core/test/acceptance/signal-components/dom_property_interpolation.spec.ts diff --git a/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/GOLDEN_PARTIAL.js deleted file mode 100644 index c8a3a681a0e..00000000000 --- a/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/GOLDEN_PARTIAL.js +++ /dev/null @@ -1,67 +0,0 @@ -/**************************************************************************************************** - * PARTIAL FILE: simple_dom_binding.js - ****************************************************************************************************/ -import { Component, signal } from '@angular/core'; -import * as i0 from "@angular/core"; -export class LiteralValueBinding { -} -LiteralValueBinding.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: LiteralValueBinding, deps: [], target: i0.ɵɵFactoryTarget.Component }); -LiteralValueBinding.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: LiteralValueBinding, isSignal: true, selector: "app", ngImport: i0, template: ``, isInline: true }); -i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: LiteralValueBinding, decorators: [{ - type: Component, - args: [{ - selector: 'app', - signals: true, - template: ``, - }] - }] }); -export class FromContextBindingStatic { - constructor() { - this.isDisabled = true; - } -} -FromContextBindingStatic.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: FromContextBindingStatic, deps: [], target: i0.ɵɵFactoryTarget.Component }); -FromContextBindingStatic.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: FromContextBindingStatic, isSignal: true, selector: "app", ngImport: i0, template: ``, isInline: true }); -i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: FromContextBindingStatic, decorators: [{ - type: Component, - args: [{ - selector: 'app', - signals: true, - template: ``, - }] - }] }); -export class FromContextBindingSignal { - constructor() { - this.isDisabled = signal(true); - } -} -FromContextBindingSignal.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: FromContextBindingSignal, deps: [], target: i0.ɵɵFactoryTarget.Component }); -FromContextBindingSignal.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: FromContextBindingSignal, isSignal: true, selector: "app", ngImport: i0, template: ``, isInline: true }); -i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: FromContextBindingSignal, decorators: [{ - type: Component, - args: [{ - selector: 'app', - signals: true, - template: ``, - }] - }] }); - -/**************************************************************************************************** - * PARTIAL FILE: simple_dom_binding.d.ts - ****************************************************************************************************/ -import * as i0 from "@angular/core"; -export declare class LiteralValueBinding { - static ɵfac: i0.ɵɵFactoryDeclaration; - static ɵcmp: i0.ɵɵComponentDeclaration; -} -export declare class FromContextBindingStatic { - isDisabled: boolean; - static ɵfac: i0.ɵɵFactoryDeclaration; - static ɵcmp: i0.ɵɵComponentDeclaration; -} -export declare class FromContextBindingSignal { - isDisabled: import("@angular/core").WritableSignal; - static ɵfac: i0.ɵɵFactoryDeclaration; - static ɵcmp: i0.ɵɵComponentDeclaration; -} - diff --git a/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/TEST_CASES.json deleted file mode 100644 index e23694c2582..00000000000 --- a/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/TEST_CASES.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "../../test_case_schema.json", - "cases": [ - { - "onlyForTemplatePipeline": true, - "description": "should support binding to DOM properties", - "inputFiles": ["simple_dom_binding.ts"], - "expectations": [ - { - "files": [{"generated": "simple_dom_binding.js", "expected": "simple_dom_binding.js"}] - } - ] - } - ] -} diff --git a/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/GOLDEN_PARTIAL.js new file mode 100644 index 00000000000..8c1a2d0f6b2 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/GOLDEN_PARTIAL.js @@ -0,0 +1,134 @@ +/**************************************************************************************************** + * PARTIAL FILE: simple_binding.js + ****************************************************************************************************/ +import { Component, signal } from '@angular/core'; +import * as i0 from "@angular/core"; +export class LiteralValueBinding { +} +LiteralValueBinding.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: LiteralValueBinding, deps: [], target: i0.ɵɵFactoryTarget.Component }); +LiteralValueBinding.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: LiteralValueBinding, isSignal: true, selector: "app", ngImport: i0, template: ``, isInline: true }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: LiteralValueBinding, decorators: [{ + type: Component, + args: [{ + selector: 'app', + signals: true, + template: ``, + }] + }] }); +export class FromContextBindingStatic { + constructor() { + this.isDisabled = true; + } +} +FromContextBindingStatic.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: FromContextBindingStatic, deps: [], target: i0.ɵɵFactoryTarget.Component }); +FromContextBindingStatic.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: FromContextBindingStatic, isSignal: true, selector: "app", ngImport: i0, template: ``, isInline: true }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: FromContextBindingStatic, decorators: [{ + type: Component, + args: [{ + selector: 'app', + signals: true, + template: ``, + }] + }] }); +export class FromContextBindingSignal { + constructor() { + this.isDisabled = signal(true); + } +} +FromContextBindingSignal.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: FromContextBindingSignal, deps: [], target: i0.ɵɵFactoryTarget.Component }); +FromContextBindingSignal.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: FromContextBindingSignal, isSignal: true, selector: "app", ngImport: i0, template: ``, isInline: true }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: FromContextBindingSignal, decorators: [{ + type: Component, + args: [{ + selector: 'app', + signals: true, + template: ``, + }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: simple_binding.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class LiteralValueBinding { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} +export declare class FromContextBindingStatic { + isDisabled: boolean; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} +export declare class FromContextBindingSignal { + isDisabled: import("@angular/core").WritableSignal; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} + +/**************************************************************************************************** + * PARTIAL FILE: with_interpolation.js + ****************************************************************************************************/ +import { Component, signal } from '@angular/core'; +import * as i0 from "@angular/core"; +export class LiteralValueBinding { +} +LiteralValueBinding.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: LiteralValueBinding, deps: [], target: i0.ɵɵFactoryTarget.Component }); +LiteralValueBinding.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: LiteralValueBinding, isSignal: true, selector: "app", ngImport: i0, template: ``, isInline: true }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: LiteralValueBinding, decorators: [{ + type: Component, + args: [{ + selector: 'app', + signals: true, + template: ``, + }] + }] }); +export class FromContextBindingStatic { + constructor() { + this.type = 'submit'; + } +} +FromContextBindingStatic.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: FromContextBindingStatic, deps: [], target: i0.ɵɵFactoryTarget.Component }); +FromContextBindingStatic.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: FromContextBindingStatic, isSignal: true, selector: "app", ngImport: i0, template: ``, isInline: true }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: FromContextBindingStatic, decorators: [{ + type: Component, + args: [{ + selector: 'app', + signals: true, + template: ``, + }] + }] }); +export class FromContextBindingSignal { + constructor() { + this.type = signal('submit'); + } +} +FromContextBindingSignal.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: FromContextBindingSignal, deps: [], target: i0.ɵɵFactoryTarget.Component }); +FromContextBindingSignal.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: FromContextBindingSignal, isSignal: true, selector: "app", ngImport: i0, template: ``, isInline: true }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: FromContextBindingSignal, decorators: [{ + type: Component, + args: [{ + selector: 'app', + signals: true, + template: ``, + }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: with_interpolation.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class LiteralValueBinding { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} +export declare class FromContextBindingStatic { + type: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} +export declare class FromContextBindingSignal { + type: import("@angular/core").WritableSignal; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} + diff --git a/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/TEST_CASES.json new file mode 100644 index 00000000000..dc77df992c4 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/TEST_CASES.json @@ -0,0 +1,25 @@ +{ + "$schema": "../../test_case_schema.json", + "cases": [ + { + "onlyForTemplatePipeline": true, + "description": "should support binding to DOM properties", + "inputFiles": ["simple_binding.ts"], + "expectations": [ + { + "files": [{"generated": "simple_binding.js", "expected": "simple_binding.js"}] + } + ] + }, + { + "onlyForTemplatePipeline": true, + "description": "should support interpolation binding to DOM properties", + "inputFiles": ["with_interpolation.ts"], + "expectations": [ + { + "files": [{"generated": "with_interpolation.js", "expected": "with_interpolation.js"}] + } + ] + } + ] +} diff --git a/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/simple_dom_binding.js b/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/simple_binding.js similarity index 100% rename from packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/simple_dom_binding.js rename to packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/simple_binding.js diff --git a/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/simple_dom_binding.ts b/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/simple_binding.ts similarity index 100% rename from packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/simple_dom_binding.ts rename to packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/simple_binding.ts diff --git a/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/with_interpolation.js b/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/with_interpolation.js new file mode 100644 index 00000000000..9cd514afcef --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/with_interpolation.js @@ -0,0 +1,27 @@ +template: function LiteralValueBinding_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵelementStart(0, "button"); + i0.ɵɵpropertyCreate(1, "title", function () { return i0.ɵɵstringifyInterpolation`This is a ${"submit"} button`; }); + i0.ɵɵelementEnd(); + } +} + +// ... + +template: function FromContextBindingStatic_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵelementStart(0, "button"); + i0.ɵɵpropertyCreate(1, "title", function () { return i0.ɵɵstringifyInterpolation`This is a ${ctx.type} button`; }); + i0.ɵɵelementEnd(); + } +} + +// ... + +template: function FromContextBindingSignal_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵelementStart(0, "button"); + i0.ɵɵpropertyCreate(1, "title", function () { return i0.ɵɵstringifyInterpolation`This is a ${ctx.type()} button`; }); + i0.ɵɵelementEnd(); + } +} \ No newline at end of file diff --git a/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/with_interpolation.ts b/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/with_interpolation.ts new file mode 100644 index 00000000000..ca169718b92 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/signal_based/property_bindings/dom/with_interpolation.ts @@ -0,0 +1,27 @@ +import {Component, signal} from '@angular/core'; + +@Component({ + selector: 'app', + signals: true, + template: ``, +}) +export class LiteralValueBinding { +} + +@Component({ + selector: 'app', + signals: true, + template: ``, +}) +export class FromContextBindingStatic { + type = 'submit'; +} + +@Component({ + selector: 'app', + signals: true, + template: ``, +}) +export class FromContextBindingSignal { + type = signal('submit'); +} diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index 26de5e46194..a0969be544d 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -182,6 +182,9 @@ export class Identifiers { static propertyCreate: o.ExternalReference = {name: 'ɵɵpropertyCreate', moduleName: CORE}; static property: o.ExternalReference = {name: 'ɵɵproperty', moduleName: CORE}; + static stringifyInterpolation: + o.ExternalReference = {name: 'ɵɵstringifyInterpolation', moduleName: CORE}; + static propertyInterpolate: o.ExternalReference = {name: 'ɵɵpropertyInterpolate', moduleName: CORE}; static propertyInterpolate1: diff --git a/packages/compiler/src/template/pipeline/ir/src/enums.ts b/packages/compiler/src/template/pipeline/ir/src/enums.ts index 6a6a076b888..6a37573f0f7 100644 --- a/packages/compiler/src/template/pipeline/ir/src/enums.ts +++ b/packages/compiler/src/template/pipeline/ir/src/enums.ts @@ -236,7 +236,13 @@ export enum ExpressionKind { /** * A reference to a temporary variable. */ + ReadTemporaryExpr, + /** + * An interpolation template string expression. Such template string expression is + * commonly expected to be part of the `PropertyCreate` operation expression. + */ + InterpolationTemplateExpr, } /** diff --git a/packages/compiler/src/template/pipeline/ir/src/expression.ts b/packages/compiler/src/template/pipeline/ir/src/expression.ts index f145c912b07..b477b747da8 100644 --- a/packages/compiler/src/template/pipeline/ir/src/expression.ts +++ b/packages/compiler/src/template/pipeline/ir/src/expression.ts @@ -18,11 +18,11 @@ import {ConsumesVarsTrait, UsesSlotIndex, UsesSlotIndexTrait, UsesVarOffset, Use /** * An `o.Expression` subtype representing a logical expression in the intermediate representation. */ -export type Expression = - LexicalReadExpr|ReferenceExpr|ShallowReferenceExpr|ContextExpr|NextContextExpr| - GetCurrentViewExpr|RestoreViewExpr|ResetViewExpr|ReadVariableExpr|PureFunctionExpr| - PureFunctionParameterExpr|PipeBindingExpr|PipeBindingVariadicExpr|SafePropertyReadExpr| - SafeKeyedReadExpr|SafeInvokeFunctionExpr|EmptyExpr|AssignTemporaryExpr|ReadTemporaryExpr; +export type Expression = LexicalReadExpr|ReferenceExpr|ShallowReferenceExpr|ContextExpr| + NextContextExpr|GetCurrentViewExpr|RestoreViewExpr|ResetViewExpr|ReadVariableExpr| + PureFunctionExpr|PureFunctionParameterExpr|PipeBindingExpr|PipeBindingVariadicExpr| + SafePropertyReadExpr|SafeKeyedReadExpr|SafeInvokeFunctionExpr|EmptyExpr|AssignTemporaryExpr| + ReadTemporaryExpr|InterpolationTemplateExpr; /** * Transformer type which converts expressions into general `o.Expression`s (which may be an @@ -661,6 +661,47 @@ export class SafeTernaryExpr extends ExpressionBase { } } +export class InterpolationTemplateExpr extends ExpressionBase { + override readonly kind = ExpressionKind.InterpolationTemplateExpr; + + constructor(public staticParts: string[], public expressionParts: o.Expression[]) { + super(); + } + + override visitExpression() { + throw new Error('Not implemented.'); + } + + override isEquivalent(e: o.Expression): boolean { + if (!(e instanceof InterpolationTemplateExpr)) { + return false; + } + if (e.staticParts.length !== this.staticParts.length) { + return false; + } + if (e.expressionParts.length !== this.expressionParts.length) { + return false; + } + return e.staticParts.every((p, i) => p === this.staticParts[i]) && + e.expressionParts.every((e, i) => e === this.expressionParts[i]); + } + + override isConstant(): boolean { + return false; + } + + override transformInternalExpressions(transform: ExpressionTransform, flags: VisitorContextFlag): + void { + this.expressionParts = + this.expressionParts.map(p => transformExpressionsInExpression(p, transform, flags)); + } + + override clone(): InterpolationTemplateExpr { + return new InterpolationTemplateExpr( + [...this.staticParts], this.expressionParts.map(p => p.clone())); + } +} + export class EmptyExpr extends ExpressionBase { override readonly kind = ExpressionKind.EmptyExpr; diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 81692a11dcf..2b0bbd568ac 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -272,11 +272,26 @@ function ingestPropertyBinding( } view.update.push(ir.createInterpolateStyleMapOp( xref, value.strings, value.expressions.map(expr => convertAst(expr, view.tpl)))); - } else { - view.update.push(ir.createInterpolatePropertyOp( - xref, bindingKind, name, value.strings, - value.expressions.map(expr => convertAst(expr, view.tpl)))); + break; } + + if (view.tpl.isSignal) { + // Allocating an XRef ID because a property create operation + // consumes a slot to store the expression. + const propertyXrefId = view.tpl.allocateXrefId(); + + view.create.push(ir.createPropertyCreateOp( + propertyXrefId, xref, name, + new ir.InterpolationTemplateExpr( + value.strings, value.expressions.map(e => convertAst(e, view.tpl))))); + + break; + } + + view.update.push(ir.createInterpolatePropertyOp( + xref, bindingKind, name, value.strings, + value.expressions.map(expr => convertAst(expr, view.tpl)))); + break; case e.BindingType.Style: if (bindingKind !== ir.ElementAttributeKind.Binding) { diff --git a/packages/compiler/src/template/pipeline/src/instruction.ts b/packages/compiler/src/template/pipeline/src/instruction.ts index 083bc375333..73ae0f507e5 100644 --- a/packages/compiler/src/template/pipeline/src/instruction.ts +++ b/packages/compiler/src/template/pipeline/src/instruction.ts @@ -265,6 +265,13 @@ export function pureFunction( ); } +export function stringifyInterpolation( + staticParts: string[], expressions: o.Expression[]): o.Expression { + return o.taggedTemplate( + o.importExpr(Identifiers.stringifyInterpolation), + new o.TemplateLiteral(staticParts.map(p => new o.TemplateLiteralElement(p)), expressions)); +} + /** * Collates the string an expression arguments for an interpolation instruction. */ diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index a01cbd9446b..9075e424d34 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -220,6 +220,8 @@ function reifyIrExpression(expr: o.Expression): o.Expression { return ng.pipeBind(expr.slot!, expr.varOffset!, expr.args); case ir.ExpressionKind.PipeBindingVariadic: return ng.pipeBindV(expr.slot!, expr.varOffset!, expr.args); + case ir.ExpressionKind.InterpolationTemplateExpr: + return ng.stringifyInterpolation(expr.staticParts, expr.expressionParts); default: throw new Error(`AssertionError: Unsupported reification of ir.Expression kind: ${ ir.ExpressionKind[(expr as ir.Expression).kind]}`); diff --git a/packages/core/src/render3/jit/environment.ts b/packages/core/src/render3/jit/environment.ts index 6bf91b473e8..42549e9f989 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]: Function} = 'ɵɵhostProperty': r3.ɵɵhostProperty, 'ɵɵproperty': r3.ɵɵproperty, 'ɵɵpropertyCreate': r3.ɵɵpropertyCreate, + 'ɵɵstringifyInterpolation': r3.ɵɵstringifyInterpolation, 'ɵɵpropertyInterpolate': r3.ɵɵpropertyInterpolate, 'ɵɵpropertyInterpolate1': r3.ɵɵpropertyInterpolate1, 'ɵɵpropertyInterpolate2': r3.ɵɵpropertyInterpolate2, diff --git a/packages/core/test/acceptance/signal-components/dom_property_bindings.spec.ts b/packages/core/test/acceptance/signal-components/dom_property_bindings.spec.ts index 61d466fe2db..6c128711d48 100644 --- a/packages/core/test/acceptance/signal-components/dom_property_bindings.spec.ts +++ b/packages/core/test/acceptance/signal-components/dom_property_bindings.spec.ts @@ -60,67 +60,3 @@ describe('dom property bindings in signal based components', () => { expect(div.tabIndex).toBe(0); }); }); - -describe('dom property interpolation in signal based components', () => { - xit('should support property interpolation in DOM bindings', () => { - @Component({ - signals: true, - template: `
`, - standalone: true, - }) - class App { - name = signal('Angular'); - } - - const fixture = TestBed.createComponent(App); - const div = fixture.nativeElement.firstChild; - - fixture.detectChanges(); - - expect(div.title).toBe('Hello, Angular!'); - }); - - describe('hand-generated code - DELETE WHEN COMPILER CAN GENERATE INTERPOLATION', () => { - it('should support property interpolation in DOM bindings - hand generated code', () => { - class TestComponent { - name = signal('Angular'); - - static ɵfac = () => new TestComponent(); - static ɵcmp = ɵɵdefineComponent({ - type: TestComponent, - selectors: [['test-cmp']], - standalone: true, - signals: true, - features: [ɵɵStandaloneFeature], - decls: 1, - vars: 2, - consts: [[3, 'title']], - template: - (rf: any, ctx: any) => { - if ((rf & 1)) { - ɵɵelement(0, 'div', 0); - ɵɵpropertyCreate( - 1, 'title', () => ɵɵstringifyInterpolation`Hello, ${ctx.name()}!`); - } - }, - styles: [] - }); - } - - @Component({ - signals: true, - template: ``, - standalone: true, - imports: [TestComponent], - }) - class App { - } - - const fixture = TestBed.createComponent(App); - const div = fixture.nativeElement.firstChild.firstChild; - - fixture.detectChanges(); - expect(div.title).toBe('Hello, Angular!'); - }); - }); -}); diff --git a/packages/core/test/acceptance/signal-components/dom_property_interpolation.spec.ts b/packages/core/test/acceptance/signal-components/dom_property_interpolation.spec.ts new file mode 100644 index 00000000000..ae79ea10a5c --- /dev/null +++ b/packages/core/test/acceptance/signal-components/dom_property_interpolation.spec.ts @@ -0,0 +1,60 @@ +/** + * @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.io/license + */ + +import {USE_TEMPLATE_PIPELINE} from '@angular/compiler/src/template/pipeline/switch/index'; +import {Component, signal} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; + +if (!USE_TEMPLATE_PIPELINE) { + console.error( + 'ERROR: Cannot run this test target without: --//packages/compiler:use_template_pipeline'); + process.exit(1); +} + +describe('Signal component DOM property interpolations', () => { + it('should bind interpolated values', () => { + @Component({ + signals: true, + template: `
`, + standalone: true, + }) + class App { + name = signal('Angular'); + } + + const fixture = TestBed.createComponent(App); + const div = fixture.nativeElement.firstChild; + + fixture.detectChanges(); + + expect(div.title).toBe('Hello, Angular!'); + }); + + it('should support updating values', () => { + @Component({ + signals: true, + template: `
`, + standalone: true, + }) + class App { + name = signal('Angular'); + } + + const fixture = TestBed.createComponent(App); + const div = fixture.nativeElement.firstChild; + + fixture.detectChanges(); + + expect(div.title).toBe('Hello, Angular!'); + + fixture.componentInstance.name.set('New Name'); + fixture.detectChanges(); + + expect(div.title).toBe('Hello, New Name!'); + }); +}); diff --git a/packages/core/test/acceptance/signal-components/input_interpolation.spec.ts b/packages/core/test/acceptance/signal-components/input_interpolation.spec.ts index 693fb4b6b57..881cdee25f0 100644 --- a/packages/core/test/acceptance/signal-components/input_interpolation.spec.ts +++ b/packages/core/test/acceptance/signal-components/input_interpolation.spec.ts @@ -12,7 +12,6 @@ import {TestBed} from '@angular/core/testing'; import {Input} from '../../../src/metadata'; - if (!USE_TEMPLATE_PIPELINE) { console.error( 'ERROR: Cannot run this test target without: --//packages/compiler:use_template_pipeline'); @@ -30,11 +29,10 @@ describe('Signal component input interpolations', () => { @Input() text = input(''); } - // TODO: compiler needs to generate the ɵɵpropertyInterpolationCreate instruction - xit('should bind interpolated values', () => { + it('should bind interpolated values', () => { @Component({ signals: true, - template: ``, + template: ``, imports: [Print], standalone: true, }) @@ -47,4 +45,26 @@ describe('Signal component input interpolations', () => { expect(fixture.nativeElement.textContent).toBe('Hello, Angular!'); }); + + it('should allow updating interpolated values', () => { + @Component({ + signals: true, + template: ``, + imports: [Print], + standalone: true, + }) + class App { + name = signal('Angular'); + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('Hello, Angular!'); + + fixture.componentInstance.name.set('New Name'); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('Hello, New Name!'); + }); }); diff --git a/packages/core/test/signals-playground/index.ts b/packages/core/test/signals-playground/index.ts index 4cd69c54831..fc4f9c67431 100644 --- a/packages/core/test/signals-playground/index.ts +++ b/packages/core/test/signals-playground/index.ts @@ -6,14 +6,14 @@ * found in the LICENSE file at https://angular.io/license */ -import {Component, input, Input} from '@angular/core'; +import {Component, input, Input, signal} from '@angular/core'; import {bootstrapApplication} from '@angular/platform-browser'; @Component({ selector: 'greet', standalone: true, signals: true, - template: `{{ counter() }}`, + template: `{{ counter() }} -- {{label()}}`, }) export class Greet { counter = input(0); @@ -24,6 +24,8 @@ export class Greet { gen = input({required: true}); gen2 = input(); + label = input(); + works(): T { return this.gen(); } @@ -37,16 +39,22 @@ export class Greet { selector: 'my-app', template: ` Hello + `, imports: [Greet], signals: true, }) export class MyApp { + name = signal('Angular'); someVar = -10; + + protected updateName() { + this.name.update(n => `${n}-`); + } } bootstrapApplication(MyApp).catch((e) => console.error(e));