diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/GOLDEN_PARTIAL.js index 1b28823b5ccc..cda5293915f4 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/GOLDEN_PARTIAL.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/GOLDEN_PARTIAL.js @@ -721,3 +721,68 @@ export declare class MyComponent { static ɵcmp: i0.ɵɵComponentDeclaration; } +/**************************************************************************************************** + * PARTIAL FILE: mixed_one_way_two_way_property_order.js + ****************************************************************************************************/ +import { Component, Directive, Input, Output } from '@angular/core'; +import * as i0 from "@angular/core"; +export class Dir { +} +Dir.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: Dir, deps: [], target: i0.ɵɵFactoryTarget.Directive }); +Dir.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: Dir, isStandalone: true, selector: "[dir]", inputs: { a: "a", b: "b", c: "c", d: "d" }, outputs: { aChange: "aChange", cChange: "cChange" }, ngImport: i0 }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: Dir, decorators: [{ + type: Directive, + args: [{ standalone: true, selector: '[dir]' }] + }], propDecorators: { a: [{ + type: Input + }], aChange: [{ + type: Output + }], b: [{ + type: Input + }], c: [{ + type: Input + }], cChange: [{ + type: Output + }], d: [{ + type: Input + }] } }); +export class App { + constructor() { + this.value = 'hi'; + } +} +App.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: App, deps: [], target: i0.ɵɵFactoryTarget.Component }); +App.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: App, isStandalone: true, selector: "ng-component", ngImport: i0, template: ` +
+ `, isInline: true, dependencies: [{ kind: "directive", type: Dir, selector: "[dir]", inputs: ["a", "b", "c", "d"], outputs: ["aChange", "cChange"] }] }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: App, decorators: [{ + type: Component, + args: [{ + standalone: true, + imports: [Dir], + template: ` +
+ `, + }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: mixed_one_way_two_way_property_order.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class Dir { + a: unknown; + aChange: unknown; + b: unknown; + c: unknown; + cChange: unknown; + d: unknown; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +export declare class App { + value: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} + diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/TEST_CASES.json index a0412d19096a..027c58e0991b 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/TEST_CASES.json @@ -196,6 +196,20 @@ ] } ] + }, + { + "description": "should maintain the binding order between one-way and two-way properties", + "inputFiles": [ + "mixed_one_way_two_way_property_order.ts" + ], + "expectations": [ + { + "failureMessage": "Incorrect template", + "files": [ + "mixed_one_way_two_way_property_order.js" + ] + } + ] } ] } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/mixed_one_way_two_way_property_order.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/mixed_one_way_two_way_property_order.js new file mode 100644 index 000000000000..ed5c26a1f51f --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/mixed_one_way_two_way_property_order.js @@ -0,0 +1,6 @@ +if (rf & 2) { + $r3$.ɵɵtwoWayProperty("a", ctx.value); + $r3$.ɵɵproperty("b", ctx.value); + $r3$.ɵɵtwoWayProperty("c", ctx.value); + $r3$.ɵɵproperty("d", ctx.value); +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/mixed_one_way_two_way_property_order.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/mixed_one_way_two_way_property_order.ts new file mode 100644 index 000000000000..3327b09186b6 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/mixed_one_way_two_way_property_order.ts @@ -0,0 +1,25 @@ +import {Component, Directive, Input, Output} from '@angular/core'; + +@Directive({standalone: true, selector: '[dir]'}) +export class Dir { + @Input() a: unknown; + @Output() aChange: unknown; + + @Input() b: unknown; + + @Input() c: unknown; + @Output() cChange: unknown; + + @Input() d: unknown; +} + +@Component({ + standalone: true, + imports: [Dir], + template: ` +
+ `, +}) +export class App { + value = 'hi'; +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/GOLDEN_PARTIAL.js index e3c96ad6f59c..1d298f51ea49 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/GOLDEN_PARTIAL.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/GOLDEN_PARTIAL.js @@ -850,3 +850,70 @@ export declare class MyComponent { static ɵcmp: i0.ɵɵComponentDeclaration; } +/**************************************************************************************************** + * PARTIAL FILE: mixed_one_way_two_way_listener_order.js + ****************************************************************************************************/ +import { Component, Directive, Input, Output } from '@angular/core'; +import * as i0 from "@angular/core"; +export class Dir { +} +Dir.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: Dir, deps: [], target: i0.ɵɵFactoryTarget.Directive }); +Dir.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: Dir, isStandalone: true, selector: "[dir]", inputs: { a: "a", c: "c" }, outputs: { aChange: "aChange", b: "b", cChange: "cChange", d: "d" }, ngImport: i0 }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: Dir, decorators: [{ + type: Directive, + args: [{ standalone: true, selector: '[dir]' }] + }], propDecorators: { a: [{ + type: Input + }], aChange: [{ + type: Output + }], b: [{ + type: Output + }], c: [{ + type: Input + }], cChange: [{ + type: Output + }], d: [{ + type: Output + }] } }); +export class App { + constructor() { + this.value = 'hi'; + this.noop = () => { }; + } +} +App.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: App, deps: [], target: i0.ɵɵFactoryTarget.Component }); +App.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: App, isStandalone: true, selector: "ng-component", ngImport: i0, template: ` +
+ `, isInline: true, dependencies: [{ kind: "directive", type: Dir, selector: "[dir]", inputs: ["a", "c"], outputs: ["aChange", "b", "cChange", "d"] }] }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: App, decorators: [{ + type: Component, + args: [{ + standalone: true, + imports: [Dir], + template: ` +
+ `, + }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: mixed_one_way_two_way_listener_order.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class Dir { + a: unknown; + aChange: unknown; + b: unknown; + c: unknown; + cChange: unknown; + d: unknown; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +export declare class App { + value: string; + noop: () => void; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} + diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/TEST_CASES.json index 0abfef1b2145..645c2d29400d 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/TEST_CASES.json @@ -344,6 +344,17 @@ "failureMessage": "Incorrect template" } ] + }, + { + "description": "should maintain the binding order between plain listeners and listeners part of a two-way binding", + "inputFiles": [ + "mixed_one_way_two_way_listener_order.ts" + ], + "expectations": [ + { + "failureMessage": "Incorrect template" + } + ] } ] } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/mixed_one_way_two_way_listener_order.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/mixed_one_way_two_way_listener_order.js new file mode 100644 index 000000000000..990dc8b83b23 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/mixed_one_way_two_way_listener_order.js @@ -0,0 +1,18 @@ +if (rf & 1) { + $r3$.ɵɵelementStart(0, "div", 0); + $r3$.ɵɵtwoWayListener("aChange", function App_Template_div_aChange_0_listener($event) { + $r3$.ɵɵtwoWayBindingSet(ctx.value, $event) || (ctx.value = $event); + return $event; + }); + $r3$.ɵɵlistener("b", function App_Template_div_b_0_listener() { + return ctx.noop(); + }); + $r3$.ɵɵtwoWayListener("cChange", function App_Template_div_cChange_0_listener($event) { + $r3$.ɵɵtwoWayBindingSet(ctx.value, $event) || (ctx.value = $event); + return $event; + }); + $r3$.ɵɵlistener("d", function App_Template_div_d_0_listener() { + return ctx.noop(); + }); + $r3$.ɵɵelementEnd(); +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/mixed_one_way_two_way_listener_order.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/mixed_one_way_two_way_listener_order.ts new file mode 100644 index 000000000000..5aca003e6094 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/mixed_one_way_two_way_listener_order.ts @@ -0,0 +1,26 @@ +import {Component, Directive, Input, Output} from '@angular/core'; + +@Directive({standalone: true, selector: '[dir]'}) +export class Dir { + @Input() a: unknown; + @Output() aChange: unknown; + + @Output() b: unknown; + + @Input() c: unknown; + @Output() cChange: unknown; + + @Output() d: unknown; +} + +@Component({ + standalone: true, + imports: [Dir], + template: ` +
+ `, +}) +export class App { + value = 'hi'; + noop = () => {}; +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/nested_two_way_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/nested_two_way_template.js index 82ec112c3c30..1566be1ea290 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/nested_two_way_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/nested_two_way_template.js @@ -1,15 +1,16 @@ function TestCmp_ng_template_1_Template(rf, ctx) { if (rf & 1) { - const $_r2$ = i0.ɵɵgetCurrentView(); - i0.ɵɵelementStart(0, "input", 0); - i0.ɵɵlistener("ngModelChange", function TestCmp_ng_template_1_Template_input_ngModelChange_0_listener($event) { - i0.ɵɵrestoreView($_r2$); - const $ctx_r1$ = i0.ɵɵnextContext(); - return i0.ɵɵresetView($ctx_r1$.name = $event); + const $_r2$ = $r3$.ɵɵgetCurrentView(); + $r3$.ɵɵelementStart(0, "input", 0); + $r3$.ɵɵtwoWayListener("ngModelChange", function TestCmp_ng_template_1_Template_input_ngModelChange_0_listener($event) { + $r3$.ɵɵrestoreView($_r2$); + const $ctx_r1$ = $r3$.ɵɵnextContext(); + $r3$.ɵɵtwoWayBindingSet($ctx_r1$.name, $event) || ($ctx_r1$.name = $event); + return $r3$.ɵɵresetView($event); }); - i0.ɵɵelementEnd(); + $r3$.ɵɵelementEnd(); } if (rf & 2) { - const $ctx_r0$ = i0.ɵɵnextContext(); - i0.ɵɵproperty("ngModel", $ctx_r0$.name); + const $ctx_r0$ = $r3$.ɵɵnextContext(); + $r3$.ɵɵtwoWayProperty("ngModel", $ctx_r0$.name); } } \ No newline at end of file diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/simple_two_way_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/simple_two_way_template.js index 6dd409247ba1..6b4ecd0b4f33 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/simple_two_way_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/simple_two_way_template.js @@ -1,13 +1,14 @@ function TestCmp_Template(rf, ctx) { if (rf & 1) { - i0.ɵɵtext(0, "Name: "); - i0.ɵɵelementStart(1, "input", 0); - i0.ɵɵlistener("ngModelChange", function TestCmp_Template_input_ngModelChange_1_listener($event) { - return ctx.name = $event; + $r3$.ɵɵtext(0, "Name: "); + $r3$.ɵɵelementStart(1, "input", 0); + $r3$.ɵɵtwoWayListener("ngModelChange", function TestCmp_Template_input_ngModelChange_1_listener($event) { + $r3$.ɵɵtwoWayBindingSet(ctx.name, $event) || (ctx.name = $event); + return $event; }); - i0.ɵɵelementEnd(); + $r3$.ɵɵelementEnd(); } if (rf & 2) { - i0.ɵɵadvance(); - i0.ɵɵproperty("ngModel", ctx.name); + $r3$.ɵɵadvance(); + $r3$.ɵɵtwoWayProperty("ngModel", ctx.name); } } diff --git a/packages/compiler-cli/test/compliance/test_cases/source_mapping/inline_templates/two_way_binding_longhand.js b/packages/compiler-cli/test/compliance/test_cases/source_mapping/inline_templates/two_way_binding_longhand.js index d5bea639d4ee..11cf9b5b150b 100644 --- a/packages/compiler-cli/test/compliance/test_cases/source_mapping/inline_templates/two_way_binding_longhand.js +++ b/packages/compiler-cli/test/compliance/test_cases/source_mapping/inline_templates/two_way_binding_longhand.js @@ -1,6 +1,6 @@ i0.ɵɵelementStart(1, "input", 0) // SOURCE: "/two_way_binding_longhand.ts" "'" +.ɵɵtwoWayListener("ngModelChange", function TestCmp_Template_input_ngModelChange_1_listener($event) {\n // SOURCE: "/two_way_binding_longhand.ts" "bindon-ngModel=\"name\">'" … // TODO: Work out how to fix the broken segment for the last item in a template .ɵɵelementEnd() // SOURCE: "/two_way_binding_longhand.ts" "'" +.ɵɵtwoWayListener("ngModelChange", function TestCmp_Template_input_ngModelChange_1_listener($event) {\n // SOURCE: "/two_way_binding_simple.ts" "[(ngModel)]=\"name\">'" … .ɵɵelementEnd() // SOURCE: "/two_way_binding_simple.ts" " { + it('should allow an && expression in a two-way binding', () => { + env.write(`test.ts`, ` + import {Component, Directive, Input, Output, EventEmitter} from '@angular/core'; + + @Directive({standalone: true, selector: '[dir]'}) + class Dir { + @Input() value: any; + @Output() valueChange = new EventEmitter(); + } + + @Component({ + standalone: true, + imports: [Dir], + template: \`
\` + }) + class App { + a = true; + b = false; + c = 'hello'; + } + `); + + env.driveMain(); + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵtwoWayProperty("value", ctx.a && !ctx.b && ctx.c);'); + expect(jsContents) + .toContain( + '{ ctx.a && !ctx.b && (i0.ɵɵtwoWayBindingSet(ctx.c, $event) || (ctx.c = $event)); return $event; }'); + }); + + it('should allow an || expression in a two-way binding', () => { + env.write(`test.ts`, ` + import {Component, Directive, Input, Output, EventEmitter} from '@angular/core'; + + @Directive({standalone: true, selector: '[dir]'}) + class Dir { + @Input() value: any; + @Output() valueChange = new EventEmitter(); + } + + @Component({ + standalone: true, + imports: [Dir], + template: \`
\` + }) + class App { + a = true; + b = false; + c = 'hello'; + } + `); + + env.driveMain(); + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵtwoWayProperty("value", ctx.a || !ctx.b || ctx.c);'); + expect(jsContents) + .toContain( + '{ ctx.a || !ctx.b || (i0.ɵɵtwoWayBindingSet(ctx.c, $event) || (ctx.c = $event)); return $event; }'); + }); + + it('should allow an ?? expression in a two-way binding', () => { + env.write(`test.ts`, ` + import {Component, Directive, Input, Output, EventEmitter} from '@angular/core'; + + @Directive({standalone: true, selector: '[dir]'}) + class Dir { + @Input() value: any; + @Output() valueChange = new EventEmitter(); + } + + @Component({ + standalone: true, + imports: [Dir], + template: \`
\` + }) + class App { + a = true; + b = false; + c = 'hello'; + } + `); + + env.driveMain(); + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('let tmp_0_0;'); + expect(jsContents) + .toContain( + 'ɵɵtwoWayProperty("value", (tmp_0_0 = (tmp_0_0 = ctx.a) !== null && ' + + 'tmp_0_0 !== undefined ? tmp_0_0 : !ctx.b) !== null && tmp_0_0 !== undefined ? tmp_0_0 : ctx.c);'); + + expect(jsContents).toContain('let tmp_b_0;'); + expect(jsContents) + .toContain( + '(tmp_b_0 = (tmp_b_0 = ctx.a) !== null && tmp_b_0 !== undefined ? ' + + 'tmp_b_0 : !ctx.b) !== null && tmp_b_0 !== undefined ? tmp_b_0 : ' + + 'i0.ɵɵtwoWayBindingSet(ctx.c, $event) || (ctx.c = $event); return $event;'); + }); + + it('should allow a ternary expression in a two-way binding', () => { + env.write(`test.ts`, ` + import {Component, Directive, Input, Output, EventEmitter} from '@angular/core'; + + @Directive({standalone: true, selector: '[dir]'}) + class Dir { + @Input() value: any; + @Output() valueChange = new EventEmitter(); + } + + @Component({ + standalone: true, + imports: [Dir], + template: \`
\` + }) + class App { + a = true; + b = false; + c = 'hello'; + } + `); + + env.driveMain(); + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵtwoWayProperty("value", ctx.a ? ctx.b : ctx.c);'); + expect(jsContents) + .toContain( + '{ ctx.a ? ctx.b : i0.ɵɵtwoWayBindingSet(ctx.c, $event) || (ctx.c = $event); return $event; }'); + }); + + it('should allow a prefixed unary expression in a two-way binding', () => { + env.write(`test.ts`, ` + import {Component, Directive, Input, Output, EventEmitter} from '@angular/core'; + + @Directive({standalone: true, selector: '[dir]'}) + class Dir { + @Input() value: any; + @Output() valueChange = new EventEmitter(); + } + + @Component({ + standalone: true, + imports: [Dir], + template: \`
\` + }) + class App { + a = true; + } + `); + + env.driveMain(); + const jsContents = env.getContents('test.js'); + + expect(jsContents).toContain('ɵɵtwoWayProperty("value", !!!ctx.a);'); + expect(jsContents) + .toContain( + '{ i0.ɵɵtwoWayBindingSet(ctx.a, $event) || (ctx.a = $event); return $event; }'); + }); + }); }); function expectTokenAtPosition( diff --git a/packages/compiler-cli/test/ngtsc/template_mapping_spec.ts b/packages/compiler-cli/test/ngtsc/template_mapping_spec.ts index 596d30964ec1..230be4e1ccbe 100644 --- a/packages/compiler-cli/test/ngtsc/template_mapping_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_mapping_spec.ts @@ -225,7 +225,7 @@ runInEachFileSystem((os) => { expectMapping(mappings, { source: '[(ngModel)]="name"', generated: - 'i0.ɵɵlistener("ngModelChange", function TestCmp_Template_input_ngModelChange_1_listener($event) { return ctx.name = $event; })', + 'i0.ɵɵtwoWayListener("ngModelChange", function TestCmp_Template_input_ngModelChange_1_listener($event) { i0.ɵɵtwoWayBindingSet(ctx.name, $event) || (ctx.name = $event); return $event; })', sourceUrl: '../test.ts' }); expectMapping(mappings, { @@ -246,7 +246,7 @@ runInEachFileSystem((os) => { expectMapping(mappings, { source: 'bindon-ngModel="name"', generated: - 'i0.ɵɵlistener("ngModelChange", function TestCmp_Template_input_ngModelChange_1_listener($event) { return ctx.name = $event; })', + 'i0.ɵɵtwoWayListener("ngModelChange", function TestCmp_Template_input_ngModelChange_1_listener($event) { i0.ɵɵtwoWayBindingSet(ctx.name, $event) || (ctx.name = $event); return $event; })', sourceUrl: '../test.ts' }); expectMapping(mappings, { diff --git a/packages/compiler/src/compiler_util/expression_converter.ts b/packages/compiler/src/compiler_util/expression_converter.ts index bd88e8385c7f..65ff3e544610 100644 --- a/packages/compiler/src/compiler_util/expression_converter.ts +++ b/packages/compiler/src/compiler_util/expression_converter.ts @@ -9,6 +9,7 @@ import * as cdAst from '../expression_parser/ast'; import * as o from '../output/output_ast'; import {ParseSourceSpan} from '../parse_util'; +import {Identifiers as R3} from '../render3/r3_identifiers'; export class EventHandlerVars { static event = o.variable('$event'); @@ -29,37 +30,12 @@ export function convertActionBinding( localResolver: LocalResolver|null, implicitReceiver: o.Expression, action: cdAst.AST, bindingId: string, baseSourceSpan?: ParseSourceSpan, implicitReceiverAccesses?: Set, globals?: Set): o.Statement[] { - if (!localResolver) { - localResolver = new DefaultLocalResolver(globals); - } - const actionWithoutBuiltins = convertPropertyBindingBuiltins( - { - createLiteralArrayConverter: (argCount: number) => { - // Note: no caching for literal arrays in actions. - return (args: o.Expression[]) => o.literalArr(args); - }, - createLiteralMapConverter: (keys: {key: string, quoted: boolean}[]) => { - // Note: no caching for literal maps in actions. - return (values: o.Expression[]) => { - const entries = keys.map((k, i) => ({ - key: k.key, - value: values[i], - quoted: k.quoted, - })); - return o.literalMap(entries); - }; - }, - createPipeConverter: (name: string) => { - throw new Error(`Illegal State: Actions are not allowed to contain pipes. Pipe: ${name}`); - } - }, - action); - + localResolver ??= new DefaultLocalResolver(globals); const visitor = new _AstToIrVisitor( localResolver, implicitReceiver, bindingId, /* supportsInterpolation */ false, baseSourceSpan, implicitReceiverAccesses); const actionStmts: o.Statement[] = []; - flattenStatements(actionWithoutBuiltins.visit(visitor, _Mode.Statement), actionStmts); + flattenStatements(convertActionBuiltins(action).visit(visitor, _Mode.Statement), actionStmts); prependTemporaryDecls(visitor.temporaryCount, bindingId, actionStmts); if (visitor.usesImplicitReceiver) { @@ -77,6 +53,95 @@ export function convertActionBinding( return actionStmts; } +export function convertAssignmentActionBinding( + localResolver: LocalResolver|null, implicitReceiver: o.Expression, action: cdAst.AST, + bindingId: string, baseSourceSpan?: ParseSourceSpan, implicitReceiverAccesses?: Set, + globals?: Set): o.Statement[] { + localResolver ??= new DefaultLocalResolver(globals); + const visitor = new _AstToIrVisitor( + localResolver, implicitReceiver, bindingId, /* supportsInterpolation */ false, baseSourceSpan, + implicitReceiverAccesses); + let convertedAction = convertActionBuiltins(action).visit(visitor, _Mode.Statement); + + // This should already have been asserted in the parser, but we verify it here just in case. + if (!(convertedAction instanceof o.ExpressionStatement)) { + throw new Error(`Illegal state: unsupported expression in two-way action binding.`); + } + + // Converts `[(ngModel)]="name"` to `twoWayBindingSet(ctx.name, $event) || (ctx.name = $event)`. + convertedAction = wrapAssignmentAction(convertedAction.expr).toStmt(); + const actionStmts: o.Statement[] = []; + flattenStatements(convertedAction, actionStmts); + prependTemporaryDecls(visitor.temporaryCount, bindingId, actionStmts); + + // Assignment events always return `$event`. + actionStmts.push(new o.ReturnStatement(EventHandlerVars.event)); + implicitReceiverAccesses?.add(EventHandlerVars.event.name); + + if (visitor.usesImplicitReceiver) { + localResolver.notifyImplicitReceiverUse(); + } + return actionStmts; +} + +function wrapAssignmentReadExpression(ast: o.ReadPropExpr|o.ReadKeyExpr): o.Expression { + return new o.ExternalExpr(R3.twoWayBindingSet) + .callFn([ast, EventHandlerVars.event]) + .or(ast.set(EventHandlerVars.event)); +} + +function isReadExpression(value: unknown): value is o.ReadPropExpr|o.ReadKeyExpr { + return value instanceof o.ReadPropExpr || value instanceof o.ReadKeyExpr; +} + +function wrapAssignmentAction(ast: o.Expression): o.Expression { + // The only officially supported expressions inside of a two-way binding are read expressions. + if (isReadExpression(ast)) { + return wrapAssignmentReadExpression(ast); + } + + // However, historically the expression parser was handling two-way events by appending `=$event` + // to the raw string before attempting to parse it. This has led to bugs over the years (see + // #37809) and to unintentionally supporting unassignable events in the two-way binding. The + // logic below aims to emulate the old behavior while still supporting the new output format + // which uses `twoWayBindingSet`. Note that the generated code doesn't necessarily make sense + // based on what the user wrote, for example the event binding for `[(value)]="a ? b : c"` + // would produce `ctx.a ? ctx.b : ctx.c = $event`. We aim to reproduce what the parser used + // to generate before #54154. + if (ast instanceof o.BinaryOperatorExpr && isReadExpression(ast.rhs)) { + // `a && b` -> `ctx.a && twoWayBindingSet(ctx.b, $event) || (ctx.b = $event)` + return new o.BinaryOperatorExpr(ast.operator, ast.lhs, wrapAssignmentReadExpression(ast.rhs)); + } + + // Note: this also supports nullish coalescing expressions which + // would've been downleveled to ternary expressions by this point. + if (ast instanceof o.ConditionalExpr && isReadExpression(ast.falseCase)) { + // `a ? b : c` -> `ctx.a ? ctx.b : twoWayBindingSet(ctx.c, $event) || (ctx.c = $event)` + return new o.ConditionalExpr( + ast.condition, ast.trueCase, wrapAssignmentReadExpression(ast.falseCase)); + } + + // `!!a` -> `twoWayBindingSet(ctx.a, $event) || (ctx.a = $event)` + // Note: previously we'd actually produce `!!(ctx.a = $event)`, but the wrapping + // node doesn't affect the result so we don't need to carry it over. + if (ast instanceof o.NotExpr) { + let expr = ast.condition; + + while (true) { + if (expr instanceof o.NotExpr) { + expr = expr.condition; + } else { + if (isReadExpression(expr)) { + return wrapAssignmentReadExpression(expr); + } + break; + } + } + } + + throw new Error(`Illegal state: unsupported expression in two-way action binding.`); +} + export interface BuiltinConverter { (args: o.Expression[]): o.Expression; } @@ -190,6 +255,31 @@ function convertBuiltins(converterFactory: BuiltinConverterFactory, ast: cdAst.A return ast.visit(visitor); } +function convertActionBuiltins(action: cdAst.AST) { + const converterFactory: BuiltinConverterFactory = { + createLiteralArrayConverter: () => { + // Note: no caching for literal arrays in actions. + return (args: o.Expression[]) => o.literalArr(args); + }, + createLiteralMapConverter: (keys: {key: string, quoted: boolean}[]) => { + // Note: no caching for literal maps in actions. + return (values: o.Expression[]) => { + const entries = keys.map((k, i) => ({ + key: k.key, + value: values[i], + quoted: k.quoted, + })); + return o.literalMap(entries); + }; + }, + createPipeConverter: (name: string) => { + throw new Error(`Illegal State: Actions are not allowed to contain pipes. Pipe: ${name}`); + } + }; + + return convertPropertyBindingBuiltins(converterFactory, action); +} + function temporaryName(bindingId: string, temporaryNumber: number): string { return `tmp_${bindingId}_${temporaryNumber}`; } diff --git a/packages/compiler/src/expression_parser/ast.ts b/packages/compiler/src/expression_parser/ast.ts index 8e0dec404b35..03eec0ed6b93 100644 --- a/packages/compiler/src/expression_parser/ast.ts +++ b/packages/compiler/src/expression_parser/ast.ts @@ -320,9 +320,9 @@ export class AbsoluteSourceSpan { constructor(public readonly start: number, public readonly end: number) {} } -export class ASTWithSource extends AST { +export class ASTWithSource extends AST { constructor( - public ast: AST, public source: string|null, public location: string, absoluteOffset: number, + public ast: T, public source: string|null, public location: string, absoluteOffset: number, public errors: ParserError[]) { super( new ParseSpan(0, source === null ? 0 : source.length), @@ -858,6 +858,15 @@ export const enum ParsedEventType { export class ParsedEvent { // Regular events have a target // Animation events have a phase + constructor( + name: string, targetOrPhase: string, type: ParsedEventType.TwoWay, + handler: ASTWithSource, sourceSpan: ParseSourceSpan, + handlerSpan: ParseSourceSpan, keySpan: ParseSourceSpan); + + constructor( + name: string, targetOrPhase: string, type: ParsedEventType, handler: ASTWithSource, + sourceSpan: ParseSourceSpan, handlerSpan: ParseSourceSpan, keySpan: ParseSourceSpan); + constructor( public name: string, public targetOrPhase: string, public type: ParsedEventType, public handler: ASTWithSource, public sourceSpan: ParseSourceSpan, diff --git a/packages/compiler/src/expression_parser/parser.ts b/packages/compiler/src/expression_parser/parser.ts index a60c08efd84c..a7ce8d53fae8 100644 --- a/packages/compiler/src/expression_parser/parser.ts +++ b/packages/compiler/src/expression_parser/parser.ts @@ -40,12 +40,6 @@ export const enum ParseFlags { * Whether an output binding is being parsed. */ Action = 1 << 0, - - /** - * Whether an assignment event is being parsed, i.e. an expression originating from - * two-way-binding aka banana-in-a-box syntax. - */ - AssignmentEvent = 1 << 1, } export class Parser { @@ -54,17 +48,15 @@ export class Parser { constructor(private _lexer: Lexer) {} parseAction( - input: string, isAssignmentEvent: boolean, location: string, absoluteOffset: number, + input: string, location: string, absoluteOffset: number, interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): ASTWithSource { this._checkNoInterpolation(input, location, interpolationConfig); const sourceToLex = this._stripComments(input); const tokens = this._lexer.tokenize(sourceToLex); - let flags = ParseFlags.Action; - if (isAssignmentEvent) { - flags |= ParseFlags.AssignmentEvent; - } const ast = - new _ParseAST(input, location, absoluteOffset, tokens, flags, this.errors, 0).parseChain(); + new _ParseAST(input, location, absoluteOffset, tokens, ParseFlags.Action, this.errors, 0) + .parseChain(); + return new ASTWithSource(ast, input, location, absoluteOffset, this.errors); } @@ -382,7 +374,7 @@ enum ParseContextFlags { Writable = 1, } -export class _ParseAST { +class _ParseAST { private rparensExpected = 0; private rbracketsExpected = 0; private rbracesExpected = 0; @@ -394,24 +386,24 @@ export class _ParseAST { // and may change for subsequent expressions visited by the parser. private sourceSpanCache = new Map(); - index: number = 0; + private index: number = 0; constructor( - public input: string, public location: string, public absoluteOffset: number, - public tokens: Token[], public parseFlags: ParseFlags, private errors: ParserError[], + private input: string, private location: string, private absoluteOffset: number, + private tokens: Token[], private parseFlags: ParseFlags, private errors: ParserError[], private offset: number) {} - peek(offset: number): Token { + private peek(offset: number): Token { const i = this.index + offset; return i < this.tokens.length ? this.tokens[i] : EOF; } - get next(): Token { + private get next(): Token { return this.peek(0); } /** Whether all the parser input has been processed. */ - get atEOF(): boolean { + private get atEOF(): boolean { return this.index >= this.tokens.length; } @@ -419,7 +411,7 @@ export class _ParseAST { * Index of the next token to be processed, or the end of the last token if all have been * processed. */ - get inputIndex(): number { + private get inputIndex(): number { return this.atEOF ? this.currentEndIndex : this.next.index + this.offset; } @@ -427,7 +419,7 @@ export class _ParseAST { * End index of the last processed token, or the start of the first token if none have been * processed. */ - get currentEndIndex(): number { + private get currentEndIndex(): number { if (this.index > 0) { const curToken = this.peek(-1); return curToken.end + this.offset; @@ -443,7 +435,7 @@ export class _ParseAST { /** * Returns the absolute offset of the start of the current token. */ - get currentAbsoluteOffset(): number { + private get currentAbsoluteOffset(): number { return this.absoluteOffset + this.inputIndex; } @@ -455,7 +447,7 @@ export class _ParseAST { * @param artificialEndIndex Optional ending index to be used if provided (and if greater than the * natural ending index) */ - span(start: number, artificialEndIndex?: number): ParseSpan { + private span(start: number, artificialEndIndex?: number): ParseSpan { let endIndex = this.currentEndIndex; if (artificialEndIndex !== undefined && artificialEndIndex > this.currentEndIndex) { endIndex = artificialEndIndex; @@ -476,7 +468,7 @@ export class _ParseAST { return new ParseSpan(start, endIndex); } - sourceSpan(start: number, artificialEndIndex?: number): AbsoluteSourceSpan { + private sourceSpan(start: number, artificialEndIndex?: number): AbsoluteSourceSpan { const serial = `${start}@${this.inputIndex}:${artificialEndIndex}`; if (!this.sourceSpanCache.has(serial)) { this.sourceSpanCache.set( @@ -485,7 +477,7 @@ export class _ParseAST { return this.sourceSpanCache.get(serial)!; } - advance() { + private advance() { this.index++; } @@ -499,7 +491,7 @@ export class _ParseAST { return ret; } - consumeOptionalCharacter(code: number): boolean { + private consumeOptionalCharacter(code: number): boolean { if (this.next.isCharacter(code)) { this.advance(); return true; @@ -508,10 +500,11 @@ export class _ParseAST { } } - peekKeywordLet(): boolean { + private peekKeywordLet(): boolean { return this.next.isKeywordLet(); } - peekKeywordAs(): boolean { + + private peekKeywordAs(): boolean { return this.next.isKeywordAs(); } @@ -521,12 +514,12 @@ export class _ParseAST { * * See `this.error` and `this.skip` for more details. */ - expectCharacter(code: number) { + private expectCharacter(code: number) { if (this.consumeOptionalCharacter(code)) return; this.error(`Missing expected ${String.fromCharCode(code)}`); } - consumeOptionalOperator(op: string): boolean { + private consumeOptionalOperator(op: string): boolean { if (this.next.isOperator(op)) { this.advance(); return true; @@ -535,16 +528,16 @@ export class _ParseAST { } } - expectOperator(operator: string) { + private expectOperator(operator: string) { if (this.consumeOptionalOperator(operator)) return; this.error(`Missing expected operator ${operator}`); } - prettyPrintToken(tok: Token): string { + private prettyPrintToken(tok: Token): string { return tok === EOF ? 'end of input' : `token ${tok}`; } - expectIdentifierOrKeyword(): string|null { + private expectIdentifierOrKeyword(): string|null { const n = this.next; if (!n.isIdentifier() && !n.isKeyword()) { if (n.isPrivateIdentifier()) { @@ -558,7 +551,7 @@ export class _ParseAST { return n.toString() as string; } - expectIdentifierOrKeywordOrString(): string { + private expectIdentifierOrKeywordOrString(): string { const n = this.next; if (!n.isIdentifier() && !n.isKeyword() && !n.isString()) { if (n.isPrivateIdentifier()) { @@ -610,12 +603,12 @@ export class _ParseAST { return new Chain(this.span(start), this.sourceSpan(start), exprs); } - parsePipe(): AST { + private parsePipe(): AST { const start = this.inputIndex; let result = this.parseExpression(); if (this.consumeOptionalOperator('|')) { if (this.parseFlags & ParseFlags.Action) { - this.error('Cannot have a pipe in an action expression'); + this.error(`Cannot have a pipe in an action expression`); } do { @@ -659,11 +652,11 @@ export class _ParseAST { return result; } - parseExpression(): AST { + private parseExpression(): AST { return this.parseConditional(); } - parseConditional(): AST { + private parseConditional(): AST { const start = this.inputIndex; const result = this.parseLogicalOr(); @@ -684,7 +677,7 @@ export class _ParseAST { } } - parseLogicalOr(): AST { + private parseLogicalOr(): AST { // '||' const start = this.inputIndex; let result = this.parseLogicalAnd(); @@ -695,7 +688,7 @@ export class _ParseAST { return result; } - parseLogicalAnd(): AST { + private parseLogicalAnd(): AST { // '&&' const start = this.inputIndex; let result = this.parseNullishCoalescing(); @@ -706,7 +699,7 @@ export class _ParseAST { return result; } - parseNullishCoalescing(): AST { + private parseNullishCoalescing(): AST { // '??' const start = this.inputIndex; let result = this.parseEquality(); @@ -717,7 +710,7 @@ export class _ParseAST { return result; } - parseEquality(): AST { + private parseEquality(): AST { // '==','!=','===','!==' const start = this.inputIndex; let result = this.parseRelational(); @@ -738,7 +731,7 @@ export class _ParseAST { return result; } - parseRelational(): AST { + private parseRelational(): AST { // '<', '>', '<=', '>=' const start = this.inputIndex; let result = this.parseAdditive(); @@ -759,7 +752,7 @@ export class _ParseAST { return result; } - parseAdditive(): AST { + private parseAdditive(): AST { // '+', '-' const start = this.inputIndex; let result = this.parseMultiplicative(); @@ -778,7 +771,7 @@ export class _ParseAST { return result; } - parseMultiplicative(): AST { + private parseMultiplicative(): AST { // '*', '%', '/' const start = this.inputIndex; let result = this.parsePrefix(); @@ -798,7 +791,7 @@ export class _ParseAST { return result; } - parsePrefix(): AST { + private parsePrefix(): AST { if (this.next.type == TokenType.Operator) { const start = this.inputIndex; const operator = this.next.strValue; @@ -821,7 +814,7 @@ export class _ParseAST { return this.parseCallChain(); } - parseCallChain(): AST { + private parseCallChain(): AST { const start = this.inputIndex; let result = this.parsePrimary(); while (true) { @@ -848,7 +841,7 @@ export class _ParseAST { } } - parsePrimary(): AST { + private parsePrimary(): AST { const start = this.inputIndex; if (this.consumeOptionalCharacter(chars.$LPAREN)) { this.rparensExpected++; @@ -912,7 +905,7 @@ export class _ParseAST { } } - parseExpressionList(terminator: number): AST[] { + private parseExpressionList(terminator: number): AST[] { const result: AST[] = []; do { @@ -925,7 +918,7 @@ export class _ParseAST { return result; } - parseLiteralMap(): LiteralMap { + private parseLiteralMap(): LiteralMap { const keys: LiteralMapKey[] = []; const values: AST[] = []; const start = this.inputIndex; @@ -958,7 +951,7 @@ export class _ParseAST { return new LiteralMap(this.span(start), this.sourceSpan(start), keys, values); } - parseAccessMember(readReceiver: AST, start: number, isSafe: boolean): AST { + private parseAccessMember(readReceiver: AST, start: number, isSafe: boolean): AST { const nameStart = this.inputIndex; const id = this.withContext(ParseContextFlags.Writable, () => { const id = this.expectIdentifierOrKeyword() ?? ''; @@ -971,7 +964,7 @@ export class _ParseAST { let receiver: AST; if (isSafe) { - if (this.consumeOptionalAssignment()) { + if (this.consumeOptionalOperator('=')) { this.error('The \'?.\' operator cannot be used in the assignment'); receiver = new EmptyExpr(this.span(start), this.sourceSpan(start)); } else { @@ -979,7 +972,7 @@ export class _ParseAST { this.span(start), this.sourceSpan(start), nameSpan, readReceiver, id); } } else { - if (this.consumeOptionalAssignment()) { + if (this.consumeOptionalOperator('=')) { if (!(this.parseFlags & ParseFlags.Action)) { this.error('Bindings cannot contain assignments'); return new EmptyExpr(this.span(start), this.sourceSpan(start)); @@ -997,7 +990,7 @@ export class _ParseAST { return receiver; } - parseCall(receiver: AST, start: number, isSafe: boolean): AST { + private parseCall(receiver: AST, start: number, isSafe: boolean): AST { const argumentStart = this.inputIndex; this.rparensExpected++; const args = this.parseCallArguments(); @@ -1010,25 +1003,7 @@ export class _ParseAST { new Call(span, sourceSpan, receiver, args, argumentSpan); } - private consumeOptionalAssignment(): boolean { - // When parsing assignment events (originating from two-way-binding aka banana-in-a-box syntax), - // it is valid for the primary expression to be terminated by the non-null operator. This - // primary expression is substituted as LHS of the assignment operator to achieve - // two-way-binding, such that the LHS could be the non-null operator. The grammar doesn't - // naturally allow for this syntax, so assignment events are parsed specially. - if ((this.parseFlags & ParseFlags.AssignmentEvent) && this.next.isOperator('!') && - this.peek(1).isOperator('=')) { - // First skip over the ! operator. - this.advance(); - // Then skip over the = operator, to fully consume the optional assignment operator. - this.advance(); - return true; - } - - return this.consumeOptionalOperator('='); - } - - parseCallArguments(): BindingPipe[] { + private parseCallArguments(): BindingPipe[] { if (this.next.isCharacter(chars.$RPAREN)) return []; const positionals: AST[] = []; do { @@ -1041,7 +1016,7 @@ export class _ParseAST { * Parses an identifier, a keyword, a string with an optional `-` in between, * and returns the string along with its absolute source span. */ - expectTemplateBindingKey(): TemplateBindingIdentifier { + private expectTemplateBindingKey(): TemplateBindingIdentifier { let result = ''; let operatorFound = false; const start = this.currentAbsoluteOffset; @@ -1117,7 +1092,7 @@ export class _ParseAST { return new TemplateBindingParseResult(bindings, [] /* warnings */, this.errors); } - parseKeyedReadOrWrite(receiver: AST, start: number, isSafe: boolean): AST { + private parseKeyedReadOrWrite(receiver: AST, start: number, isSafe: boolean): AST { return this.withContext(ParseContextFlags.Writable, () => { this.rbracketsExpected++; const key = this.parsePipe(); @@ -1258,7 +1233,7 @@ export class _ParseAST { * Records an error and skips over the token stream until reaching a recoverable point. See * `this.skip` for more details on token skipping. */ - error(message: string, index: number|null = null) { + private error(message: string, index: number|null = null) { this.errors.push(new ParserError(message, this.input, this.locationText(index), this.location)); this.skip(); } diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index db066b4117fa..c46d8eca4724 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -355,6 +355,11 @@ export class Identifiers { static contentQuerySignal: o.ExternalReference = {name: 'ɵɵcontentQuerySignal', moduleName: CORE}; static queryAdvance: o.ExternalReference = {name: 'ɵɵqueryAdvance', moduleName: CORE}; + // Two-way bindings + static twoWayProperty: o.ExternalReference = {name: 'ɵɵtwoWayProperty', moduleName: CORE}; + static twoWayBindingSet: o.ExternalReference = {name: 'ɵɵtwoWayBindingSet', moduleName: CORE}; + static twoWayListener: o.ExternalReference = {name: 'ɵɵtwoWayListener', moduleName: CORE}; + static NgOnChangesFeature: o.ExternalReference = {name: 'ɵɵNgOnChangesFeature', moduleName: CORE}; static InheritDefinitionFeature: diff --git a/packages/compiler/src/render3/r3_template_transform.ts b/packages/compiler/src/render3/r3_template_transform.ts index 7cc85aefc2e0..2207a55d51b1 100644 --- a/packages/compiler/src/render3/r3_template_transform.ts +++ b/packages/compiler/src/render3/r3_template_transform.ts @@ -605,7 +605,7 @@ class HtmlAstToIvyAst implements html.Visitor { boundEvents: t.BoundEvent[], keySpan: ParseSourceSpan) { const events: ParsedEvent[] = []; this.bindingParser.parseEvent( - `${name}Change`, `${expression} =$event`, /* isAssignmentEvent */ true, sourceSpan, + `${name}Change`, expression, /* isAssignmentEvent */ true, sourceSpan, valueSpan || sourceSpan, targetMatchableAttrs, events, keySpan); addEvents(events, boundEvents); } diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index 38d4986b3a54..005a215095d6 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {BuiltinFunctionCall, convertActionBinding, convertPropertyBinding, convertPureComponentScopeFunction, convertUpdateArguments, LocalResolver} from '../../compiler_util/expression_converter'; +import {BuiltinFunctionCall, convertActionBinding, convertAssignmentActionBinding, convertPropertyBinding, convertPureComponentScopeFunction, convertUpdateArguments, LocalResolver} from '../../compiler_util/expression_converter'; import {ConstantPool} from '../../constant_pool'; import * as core from '../../core'; import {AST, AstMemoryEfficientTransformer, BindingPipe, BindingType, Call, ImplicitReceiver, Interpolation, LiteralArray, LiteralMap, LiteralPrimitive, ParsedEventType, PropertyRead} from '../../expression_parser/ast'; @@ -82,9 +82,13 @@ export function prepareEventListenerParameters( const implicitReceiverExpr = (scope === null || scope.bindingLevel === 0) ? o.variable(CONTEXT_NAME) : scope.getOrCreateSharedContextVar(0); - const bindingStatements = convertActionBinding( - scope, implicitReceiverExpr, handler, 'b', eventAst.handlerSpan, implicitReceiverAccesses, - EVENT_BINDING_SCOPE_GLOBALS); + const bindingStatements = eventAst.type === ParsedEventType.TwoWay ? + convertAssignmentActionBinding( + scope, implicitReceiverExpr, handler, 'b', eventAst.handlerSpan, implicitReceiverAccesses, + EVENT_BINDING_SCOPE_GLOBALS) : + convertActionBinding( + scope, implicitReceiverExpr, handler, 'b', eventAst.handlerSpan, implicitReceiverAccesses, + EVENT_BINDING_SCOPE_GLOBALS); const statements = []; const variableDeclarations = scope?.variableDeclarations(); const restoreViewStatement = scope?.restoreViewStatement(); @@ -777,7 +781,8 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver if (element.outputs.length > 0) { for (const outputAst of element.outputs) { this.creationInstruction( - outputAst.sourceSpan, R3.listener, + outputAst.sourceSpan, + outputAst.type === ParsedEventType.TwoWay ? R3.twoWayListener : R3.listener, this.prepareListenerParameter(element.name, outputAst, elementIndex)); } } @@ -804,7 +809,7 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver // special value to symbolize that there is no RHS to this binding // TODO (matsko): revisit this once FW-959 is approached const emptyValueBindInstruction = o.literal(undefined); - const propertyBindings: Omit[] = []; + const propertyBindings: Instruction[] = []; const attributeBindings: Omit[] = []; // Generate element input bindings @@ -826,6 +831,7 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver propertyBindings.push({ span: input.sourceSpan, + reference: R3.property, paramsOrFn: getBindingFunctionParams( () => hasValue ? this.convertPropertyBinding(value) : emptyValueBindInstruction, prepareSyntheticPropertyName(input.name)) @@ -866,6 +872,8 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver } this.allocateBindingSlots(value); + // Note: we don't separate two-way property bindings and regular ones, + // because their assignment order needs to be maintained. if (inputType === BindingType.Property || inputType === BindingType.TwoWay) { if (value instanceof Interpolation) { // prop="{{value}}" and friends @@ -877,6 +885,7 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver // Collect all the properties so that we can chain into a single function at the end. propertyBindings.push({ span: input.sourceSpan, + reference: inputType === BindingType.TwoWay ? R3.twoWayProperty : R3.property, paramsOrFn: getBindingFunctionParams( () => this.convertPropertyBinding(value), attrName, params) }); @@ -912,7 +921,8 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver for (const propertyBinding of propertyBindings) { this.updateInstructionWithAdvance( - elementIndex, propertyBinding.span, R3.property, propertyBinding.paramsOrFn); + elementIndex, propertyBinding.span, propertyBinding.reference, + propertyBinding.paramsOrFn); } for (const attributeBinding of attributeBindings) { @@ -1048,7 +1058,8 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver // Generate listeners for directive output for (const outputAst of template.outputs) { this.creationInstruction( - outputAst.sourceSpan, R3.listener, + outputAst.sourceSpan, + outputAst.type === ParsedEventType.TwoWay ? R3.twoWayListener : R3.listener, this.prepareListenerParameter('ng_template', outputAst, templateIndex)); } } diff --git a/packages/compiler/src/render3/view/util.ts b/packages/compiler/src/render3/view/util.ts index 4d80e1e9ede6..476f2f59d87f 100644 --- a/packages/compiler/src/render3/view/util.ts +++ b/packages/compiler/src/render3/view/util.ts @@ -114,6 +114,8 @@ const CHAINABLE_INSTRUCTIONS = new Set([ R3.textInterpolate8, R3.textInterpolateV, R3.templateCreate, + R3.twoWayProperty, + R3.twoWayListener, ]); /** diff --git a/packages/compiler/src/template/pipeline/ir/src/enums.ts b/packages/compiler/src/template/pipeline/ir/src/enums.ts index 05416543b8d9..c3016425295b 100644 --- a/packages/compiler/src/template/pipeline/ir/src/enums.ts +++ b/packages/compiler/src/template/pipeline/ir/src/enums.ts @@ -195,6 +195,16 @@ export enum OpKind { */ Repeater, + /** + * An operation to bind an expression to the property side of a two-way binding. + */ + TwoWayProperty, + + /** + * An operation declaring the event side of a two-way binding. + */ + TwoWayListener, + /** * The start of an i18n block. */ @@ -380,6 +390,11 @@ export enum ExpressionKind { * An expression that will be automatically extracted to the component const array. */ ConstCollected, + + /** + * Operation that sets the value of a two-way binding. + */ + TwoWayBindingSet, } export enum VariableFlags { @@ -474,6 +489,11 @@ export enum BindingKind { * Animation property bindings. */ Animation, + + /** + * Property side of a two-way binding. + */ + TwoWayProperty, } /** diff --git a/packages/compiler/src/template/pipeline/ir/src/expression.ts b/packages/compiler/src/template/pipeline/ir/src/expression.ts index 09d4f253d046..9d7dd39a7e52 100644 --- a/packages/compiler/src/template/pipeline/ir/src/expression.ts +++ b/packages/compiler/src/template/pipeline/ir/src/expression.ts @@ -20,11 +20,11 @@ import {ConsumesVarsTrait, UsesVarOffset, UsesVarOffsetTrait} from './traits'; /** * An `o.Expression` subtype representing a logical expression in the intermediate representation. */ -export type Expression = - LexicalReadExpr|ReferenceExpr|ContextExpr|NextContextExpr|GetCurrentViewExpr|RestoreViewExpr| - ResetViewExpr|ReadVariableExpr|PureFunctionExpr|PureFunctionParameterExpr|PipeBindingExpr| - PipeBindingVariadicExpr|SafePropertyReadExpr|SafeKeyedReadExpr|SafeInvokeFunctionExpr|EmptyExpr| - AssignTemporaryExpr|ReadTemporaryExpr|SlotLiteralExpr|ConditionalCaseExpr|ConstCollectedExpr; +export type Expression = LexicalReadExpr|ReferenceExpr|ContextExpr|NextContextExpr| + GetCurrentViewExpr|RestoreViewExpr|ResetViewExpr|ReadVariableExpr|PureFunctionExpr| + PureFunctionParameterExpr|PipeBindingExpr|PipeBindingVariadicExpr|SafePropertyReadExpr| + SafeKeyedReadExpr|SafeInvokeFunctionExpr|EmptyExpr|AssignTemporaryExpr|ReadTemporaryExpr| + SlotLiteralExpr|ConditionalCaseExpr|ConstCollectedExpr|TwoWayBindingSetExpr; /** * Transformer type which converts expressions into general `o.Expression`s (which may be an @@ -69,7 +69,7 @@ export class LexicalReadExpr extends ExpressionBase { override visitExpression(visitor: o.ExpressionVisitor, context: any): void {} - override isEquivalent(other: LexicalReadExpr): boolean { + override isEquivalent(other: LexicalReadExpr): boolean { // We assume that the lexical reads are in the same context, which must be true for parent // expressions to be equivalent. // TODO: is this generally safe? @@ -305,6 +305,36 @@ export class ResetViewExpr extends ExpressionBase { } } +export class TwoWayBindingSetExpr extends ExpressionBase { + override readonly kind = ExpressionKind.TwoWayBindingSet; + + constructor(public target: o.Expression, public value: o.Expression) { + super(); + } + + override visitExpression(visitor: o.ExpressionVisitor, context: any): void { + this.target.visitExpression(visitor, context); + this.value.visitExpression(visitor, context); + } + + override isEquivalent(other: TwoWayBindingSetExpr): boolean { + return this.target.isEquivalent(other.target) && this.value.isEquivalent(other.value); + } + + override isConstant(): boolean { + return false; + } + + override transformInternalExpressions(transform: ExpressionTransform, flags: VisitorContextFlag) { + this.target = transformExpressionsInExpression(this.target, transform, flags); + this.value = transformExpressionsInExpression(this.value, transform, flags); + } + + override clone(): TwoWayBindingSetExpr { + return new TwoWayBindingSetExpr(this.target, this.value); + } +} + /** * Read of a variable declared as an `ir.VariableOp` and referenced through its `ir.XrefId`. */ @@ -534,7 +564,7 @@ export class SafePropertyReadExpr extends ExpressionBase { this.receiver.visitExpression(visitor, context); } - override isEquivalent(): boolean { + override isEquivalent(): boolean { return false; } @@ -565,7 +595,7 @@ export class SafeKeyedReadExpr extends ExpressionBase { this.index.visitExpression(visitor, context); } - override isEquivalent(): boolean { + override isEquivalent(): boolean { return false; } @@ -598,7 +628,7 @@ export class SafeInvokeFunctionExpr extends ExpressionBase { } } - override isEquivalent(): boolean { + override isEquivalent(): boolean { return false; } @@ -631,7 +661,7 @@ export class SafeTernaryExpr extends ExpressionBase { this.expr.visitExpression(visitor, context); } - override isEquivalent(): boolean { + override isEquivalent(): boolean { return false; } @@ -683,7 +713,7 @@ export class AssignTemporaryExpr extends ExpressionBase { this.expr.visitExpression(visitor, context); } - override isEquivalent(): boolean { + override isEquivalent(): boolean { return false; } @@ -714,7 +744,7 @@ export class ReadTemporaryExpr extends ExpressionBase { override visitExpression(visitor: o.ExpressionVisitor, context: any): any {} - override isEquivalent(): boolean { + override isEquivalent(): boolean { return this.xref === this.xref; } @@ -884,6 +914,11 @@ export function transformExpressionsInOp( op.sanitizer = op.sanitizer && transformExpressionsInExpression(op.sanitizer, transform, flags); break; + case OpKind.TwoWayProperty: + op.expression = transformExpressionsInExpression(op.expression, transform, flags); + op.sanitizer = + op.sanitizer && transformExpressionsInExpression(op.sanitizer, transform, flags); + break; case OpKind.I18nExpression: op.expression = transformExpressionsInExpression(op.expression, transform, flags); break; @@ -912,6 +947,7 @@ export function transformExpressionsInOp( } break; case OpKind.Listener: + case OpKind.TwoWayListener: for (const innerOp of op.handlerOps) { transformExpressionsInOp(innerOp, transform, flags | VisitorContextFlag.InChildOperation); } diff --git a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts index ef411fe277f8..54d7aaa43f44 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts @@ -23,11 +23,12 @@ import type {UpdateOp} from './update'; /** * An operation usable on the creation side of the IR. */ -export type CreateOp = ListEndOp|StatementOp|ElementOp|ElementStartOp| - ElementEndOp|ContainerOp|ContainerStartOp|ContainerEndOp|TemplateOp|EnableBindingsOp| - DisableBindingsOp|TextOp|ListenerOp|PipeOp|VariableOp|NamespaceOp|ProjectionDefOp| - ProjectionOp|ExtractedAttributeOp|DeferOp|DeferOnOp|RepeaterCreateOp|I18nMessageOp|I18nOp| - I18nStartOp|I18nEndOp|IcuStartOp|IcuEndOp|IcuPlaceholderOp|I18nContextOp|I18nAttributesOp; +export type CreateOp = + ListEndOp|StatementOp|ElementOp|ElementStartOp|ElementEndOp|ContainerOp| + ContainerStartOp|ContainerEndOp|TemplateOp|EnableBindingsOp|DisableBindingsOp|TextOp|ListenerOp| + TwoWayListenerOp|PipeOp|VariableOp|NamespaceOp|ProjectionDefOp|ProjectionOp| + ExtractedAttributeOp|DeferOp|DeferOnOp|RepeaterCreateOp|I18nMessageOp|I18nOp|I18nStartOp| + I18nEndOp|IcuStartOp|IcuEndOp|IcuPlaceholderOp|I18nContextOp|I18nAttributesOp; /** * An operation representing the creation of an element or container. @@ -577,6 +578,60 @@ export function createListenerOp( }; } +/** + * Logical operation representing the event side of a two-way binding on an element + * in the creation IR. + */ +export interface TwoWayListenerOp extends Op { + kind: OpKind.TwoWayListener; + + target: XrefId; + targetSlot: SlotHandle; + + /** + * Name of the event which is being listened to. + */ + name: string; + + /** + * Tag name of the element on which this listener is placed. + */ + tag: string|null; + + /** + * A list of `UpdateOp`s representing the body of the event listener. + */ + handlerOps: OpList; + + /** + * Name of the function + */ + handlerFnName: string|null; + + sourceSpan: ParseSourceSpan; +} + +/** + * Create a `TwoWayListenerOp`. + */ +export function createTwoWayListenerOp( + target: XrefId, targetSlot: SlotHandle, name: string, tag: string|null, + handlerOps: Array, sourceSpan: ParseSourceSpan): TwoWayListenerOp { + const handlerList = new OpList(); + handlerList.push(handlerOps); + return { + kind: OpKind.TwoWayListener, + target, + targetSlot, + tag, + name, + handlerOps: handlerList, + handlerFnName: null, + sourceSpan, + ...NEW_OP, + }; +} + export interface PipeOp extends Op, ConsumesSlotOpTrait { kind: OpKind.Pipe; xref: XrefId; 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 4e06a2cf5cd9..a777f732b3c3 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/update.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/update.ts @@ -22,7 +22,8 @@ import {ListEndOp, NEW_OP, StatementOp, VariableOp} from './shared'; /** * An operation usable on the update side of the IR. */ -export type UpdateOp = ListEndOp|StatementOp|PropertyOp|AttributeOp|StylePropOp| +export type UpdateOp = + ListEndOp|StatementOp|PropertyOp|TwoWayPropertyOp|AttributeOp|StylePropOp| ClassPropOp|StyleMapOp|ClassMapOp|InterpolateTextOp|AdvanceOp|VariableOp|BindingOp| HostPropertyOp|ConditionalOp|I18nExpressionOp|I18nApplyOp|RepeaterOp|DeferWhenOp; @@ -239,6 +240,78 @@ export function createPropertyOp( }; } +/** + * A logical operation representing the property binding side of a two-way binding in the update IR. + */ +export interface TwoWayPropertyOp extends Op, ConsumesVarsTrait, + DependsOnSlotContextOpTrait { + kind: OpKind.TwoWayProperty; + + /** + * Reference to the element on which the property is bound. + */ + target: XrefId; + + /** + * Name of the property. + */ + name: string; + + /** + * Expression which is bound to the property. + */ + expression: o.Expression; + + /** + * The security context of the binding. + */ + securityContext: SecurityContext|SecurityContext[]; + + /** + * The sanitizer for this property. + */ + sanitizer: o.Expression|null; + + isStructuralTemplateAttribute: boolean; + + /** + * The kind of template targeted by the binding, or null if this binding does not target a + * template. + */ + templateKind: TemplateKind|null; + + i18nContext: XrefId|null; + i18nMessage: i18n.Message|null; + + sourceSpan: ParseSourceSpan; +} + +/** + * Create a `TwoWayPropertyOp`. + */ +export function createTwoWayPropertyOp( + target: XrefId, name: string, expression: o.Expression, + securityContext: SecurityContext|SecurityContext[], isStructuralTemplateAttribute: boolean, + templateKind: TemplateKind|null, i18nContext: XrefId|null, i18nMessage: i18n.Message|null, + sourceSpan: ParseSourceSpan): TwoWayPropertyOp { + return { + kind: OpKind.TwoWayProperty, + target, + name, + expression, + securityContext, + sanitizer: null, + isStructuralTemplateAttribute, + templateKind, + i18nContext, + i18nMessage, + sourceSpan, + ...TRAIT_DEPENDS_ON_SLOT_CONTEXT, + ...TRAIT_CONSUMES_VARS, + ...NEW_OP, + }; +} + /** * A logical operation representing binding to a style property in the update IR. */ diff --git a/packages/compiler/src/template/pipeline/src/compilation.ts b/packages/compiler/src/template/pipeline/src/compilation.ts index aa932d7319d1..ab76bfe9b5e9 100644 --- a/packages/compiler/src/template/pipeline/src/compilation.ts +++ b/packages/compiler/src/template/pipeline/src/compilation.ts @@ -179,7 +179,7 @@ export abstract class CompilationUnit { * ops(): Generator { for (const op of this.create) { yield op; - if (op.kind === ir.OpKind.Listener) { + if (op.kind === ir.OpKind.Listener || op.kind === ir.OpKind.TwoWayListener) { for (const listenerOp of op.handlerOps) { yield listenerOp; } diff --git a/packages/compiler/src/template/pipeline/src/emit.ts b/packages/compiler/src/template/pipeline/src/emit.ts index 59a2f2518041..6dcb863864d2 100644 --- a/packages/compiler/src/template/pipeline/src/emit.ts +++ b/packages/compiler/src/template/pipeline/src/emit.ts @@ -63,6 +63,7 @@ import {resolveI18nElementPlaceholders} from './phases/resolve_i18n_element_plac import {resolveI18nExpressionPlaceholders} from './phases/resolve_i18n_expression_placeholders'; import {resolveNames} from './phases/resolve_names'; import {resolveSanitizers} from './phases/resolve_sanitizers'; +import {transformTwoWayBindingSet} from './phases/transform_two_way_binding_set'; import {saveAndRestoreView} from './phases/save_restore_view'; import {allocateSlots} from './phases/slot_allocation'; import {specializeStyleBindings} from './phases/style_binding_specialization'; @@ -118,6 +119,7 @@ const phases: Phase[] = [ {kind: Kind.Tmpl, fn: generateTrackVariables}, {kind: Kind.Both, fn: resolveNames}, {kind: Kind.Tmpl, fn: resolveDeferTargetNames}, + {kind: Kind.Tmpl, fn: transformTwoWayBindingSet}, {kind: Kind.Tmpl, fn: optimizeTrackFns}, {kind: Kind.Both, fn: resolveContexts}, {kind: Kind.Both, fn: resolveSanitizers}, diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index d92b1aef90d7..634cac7e3f1a 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -846,8 +846,7 @@ function convertAstWithInterpolation( // TODO: Can we populate Template binding kinds in ingest? const BINDING_KINDS = new Map([ [e.BindingType.Property, ir.BindingKind.Property], - // TODO(crisbeto): we'll need a different BindingKind for two-way bindings. - [e.BindingType.TwoWay, ir.BindingKind.Property], + [e.BindingType.TwoWay, ir.BindingKind.TwoWayProperty], [e.BindingType.Attribute, ir.BindingKind.Attribute], [e.BindingType.Class, ir.BindingKind.ClassName], [e.BindingType.Style, ir.BindingKind.StyleProperty], @@ -923,10 +922,17 @@ function ingestElementBindings( throw Error('Animation listener should have a phase'); } - unit.create.push(ir.createListenerOp( - op.xref, op.handle, output.name, op.tag, - makeListenerHandlerOps(unit, output.handler, output.handlerSpan), output.phase, - output.target, false, output.sourceSpan)); + if (output.type === e.ParsedEventType.TwoWay) { + unit.create.push(ir.createTwoWayListenerOp( + op.xref, op.handle, output.name, op.tag, + makeTwoWayListenerHandlerOps(unit, output.handler, output.handlerSpan), + output.sourceSpan)); + } else { + unit.create.push(ir.createListenerOp( + op.xref, op.handle, output.name, op.tag, + makeListenerHandlerOps(unit, output.handler, output.handlerSpan), output.phase, + output.target, false, output.sourceSpan)); + } } // If any of the bindings on this element have an i18n message, then an i18n attrs configuration @@ -984,10 +990,17 @@ function ingestTemplateBindings( } if (templateKind === ir.TemplateKind.NgTemplate) { - unit.create.push(ir.createListenerOp( - op.xref, op.handle, output.name, op.tag, - makeListenerHandlerOps(unit, output.handler, output.handlerSpan), output.phase, - output.target, false, output.sourceSpan)); + if (output.type === e.ParsedEventType.TwoWay) { + unit.create.push(ir.createTwoWayListenerOp( + op.xref, op.handle, output.name, op.tag, + makeTwoWayListenerHandlerOps(unit, output.handler, output.handlerSpan), + output.sourceSpan)); + } else { + unit.create.push(ir.createListenerOp( + op.xref, op.handle, output.name, op.tag, + makeListenerHandlerOps(unit, output.handler, output.handlerSpan), output.phase, + output.target, false, output.sourceSpan)); + } } if (templateKind === ir.TemplateKind.Structural && output.type !== e.ParsedEventType.Animation) { @@ -1043,15 +1056,22 @@ function createTemplateBinding( // If this is a structural template, then several kinds of bindings should not result in an // update instruction. if (templateKind === ir.TemplateKind.Structural) { - if (!isStructuralTemplateAttribute && - (type === e.BindingType.Property || type === e.BindingType.TwoWay || - type === e.BindingType.Class || type === e.BindingType.Style)) { - // Because this binding doesn't really target the ng-template, it must be a binding on an - // inner node of a structural template. We can't skip it entirely, because we still need it on - // the ng-template's consts (e.g. for the purposes of directive matching). However, we should - // not generate an update instruction for it. - return ir.createExtractedAttributeOp( - xref, ir.BindingKind.Property, null, name, null, null, i18nMessage, securityContext); + if (!isStructuralTemplateAttribute) { + switch (type) { + case e.BindingType.Property: + case e.BindingType.Class: + case e.BindingType.Style: + // Because this binding doesn't really target the ng-template, it must be a binding on an + // inner node of a structural template. We can't skip it entirely, because we still need + // it on the ng-template's consts (e.g. for the purposes of directive matching). However, + // we should not generate an update instruction for it. + return ir.createExtractedAttributeOp( + xref, ir.BindingKind.Property, null, name, null, null, i18nMessage, securityContext); + case e.BindingType.TwoWay: + return ir.createExtractedAttributeOp( + xref, ir.BindingKind.TwoWayProperty, null, name, null, null, i18nMessage, + securityContext); + } } if (!isTextBinding && (type === e.BindingType.Attribute || type === e.BindingType.Animation)) { @@ -1110,6 +1130,29 @@ function makeListenerHandlerOps( return handlerOps; } +function makeTwoWayListenerHandlerOps( + unit: CompilationUnit, handler: e.AST, handlerSpan: ParseSourceSpan): ir.UpdateOp[] { + handler = astOf(handler); + const handlerOps = new Array(); + + if (handler instanceof e.Chain) { + if (handler.expressions.length === 1) { + handler = handler.expressions[0]; + } else { + // This is validated during parsing already, but we do it here just in case. + throw new Error('Expected two-way listener to have a single expression.'); + } + } + + const handlerExpr = convertAst(handler, unit.job, handlerSpan); + const eventReference = new ir.LexicalReadExpr('$event'); + const twoWaySetExpr = new ir.TwoWayBindingSetExpr(handlerExpr, eventReference); + + handlerOps.push(ir.createStatementOp(new o.ExpressionStatement(twoWaySetExpr))); + handlerOps.push(ir.createStatementOp(new o.ReturnStatement(eventReference))); + return handlerOps; +} + function astOf(ast: e.AST|e.ASTWithSource): e.AST { return ast instanceof e.ASTWithSource ? ast.ast : ast; } diff --git a/packages/compiler/src/template/pipeline/src/instruction.ts b/packages/compiler/src/template/pipeline/src/instruction.ts index 5026a6e0fd63..311b41eedb6e 100644 --- a/packages/compiler/src/template/pipeline/src/instruction.ts +++ b/packages/compiler/src/template/pipeline/src/instruction.ts @@ -112,6 +112,15 @@ export function listener( syntheticHost ? Identifiers.syntheticHostListener : Identifiers.listener, args, sourceSpan); } +export function twoWayBindingSet(target: o.Expression, value: o.Expression): o.Expression { + return o.importExpr(Identifiers.twoWayBindingSet).callFn([target, value]); +} + +export function twoWayListener( + name: string, handlerFn: o.Expression, sourceSpan: ParseSourceSpan): ir.CreateOp { + return call(Identifiers.twoWayListener, [o.literal(name), handlerFn], sourceSpan); +} + export function pipe(slot: number, name: string): ir.CreateOp { return call( Identifiers.pipe, @@ -325,6 +334,16 @@ export function property( return call(Identifiers.property, args, sourceSpan); } +export function twoWayProperty( + name: string, expression: o.Expression, sanitizer: o.Expression|null, + sourceSpan: ParseSourceSpan): ir.UpdateOp { + const args = [o.literal(name), expression]; + if (sanitizer !== null) { + args.push(sanitizer); + } + return call(Identifiers.twoWayProperty, args, sourceSpan); +} + export function attribute( name: string, expression: o.Expression, sanitizer: o.Expression|null, namespace: string|null): ir.UpdateOp { 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 b7ad93ac3172..987e53cc2d98 100644 --- a/packages/compiler/src/template/pipeline/src/phases/attribute_extraction.ts +++ b/packages/compiler/src/template/pipeline/src/phases/attribute_extraction.ts @@ -46,6 +46,14 @@ export function extractAttributes(job: CompilationJob): void { lookupElement(elements, op.target)); } break; + case ir.OpKind.TwoWayProperty: + ir.OpList.insertBefore( + ir.createExtractedAttributeOp( + op.target, ir.BindingKind.TwoWayProperty, null, op.name, /* expression */ null, + /* i18nContext */ null, + /* i18nMessage */ null, op.securityContext), + lookupElement(elements, op.target)); + break; case ir.OpKind.StyleProp: case ir.OpKind.ClassProp: // TODO: Can style or class bindings be i18n attributes? @@ -84,6 +92,17 @@ export function extractAttributes(job: CompilationJob): void { } } break; + case ir.OpKind.TwoWayListener: + // Two-way listeners aren't supported in host bindings. + if (job.kind !== CompilationJobKind.Host) { + const extractedAttributeOp = ir.createExtractedAttributeOp( + op.target, ir.BindingKind.Property, null, op.name, /* expression */ null, + /* i18nContext */ null, + /* i18nMessage */ null, SecurityContext.NONE); + ir.OpList.insertBefore( + extractedAttributeOp, lookupElement(elements, op.target)); + } + break; } } } 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 1f8358e23a7e..18df18f6e621 100644 --- a/packages/compiler/src/template/pipeline/src/phases/binding_specialization.ts +++ b/packages/compiler/src/template/pipeline/src/phases/binding_specialization.ts @@ -7,6 +7,7 @@ */ import {splitNsName} from '../../../../ml_parser/tags'; +import * as o from '../../../../output/output_ast'; import * as ir from '../../ir'; import {CompilationJob, CompilationJobKind} from '../compilation'; @@ -72,6 +73,22 @@ export function specializeBindings(job: CompilationJob): void { } break; + case ir.BindingKind.TwoWayProperty: + if (!(op.expression instanceof o.Expression)) { + // We shouldn't be able to hit this code path since interpolations in two-way bindings + // result in a parser error. We assert here so that downstream we can assume that + // the value is always an expression. + throw new Error( + `Expected value of two-way property binding "${op.name}" to be an expression`); + } + + ir.OpList.replace( + op, + ir.createTwoWayPropertyOp( + op.target, op.name, op.expression, op.securityContext, + op.isStructuralTemplateAttribute, op.templateKind, op.i18nContext, op.i18nMessage, + op.sourceSpan)); + break; case ir.BindingKind.I18n: case ir.BindingKind.ClassName: case ir.BindingKind.StyleProperty: diff --git a/packages/compiler/src/template/pipeline/src/phases/chaining.ts b/packages/compiler/src/template/pipeline/src/phases/chaining.ts index b5c32261f376..f2ba770aec83 100644 --- a/packages/compiler/src/template/pipeline/src/phases/chaining.ts +++ b/packages/compiler/src/template/pipeline/src/phases/chaining.ts @@ -38,6 +38,8 @@ const CHAINABLE = new Set([ R3.syntheticHostListener, R3.syntheticHostProperty, R3.templateCreate, + R3.twoWayProperty, + R3.twoWayListener, ]); /** diff --git a/packages/compiler/src/template/pipeline/src/phases/const_collection.ts b/packages/compiler/src/template/pipeline/src/phases/const_collection.ts index b07758243142..f8b3055ce115 100644 --- a/packages/compiler/src/template/pipeline/src/phases/const_collection.ts +++ b/packages/compiler/src/template/pipeline/src/phases/const_collection.ts @@ -98,7 +98,12 @@ const FLYWEIGHT_ARRAY: ReadonlyArray = Object.freeze>(); - private byKind = new Map; + private byKind = new Map< + // Property bindings are excluded here, because they need to be tracked in the same + // array to maintain their order. They're tracked in the `propertyBindings` array. + Exclude, + o.Expression[]>; + private propertyBindings: o.Expression[]|null = null; projectAs: string|null = null; @@ -115,7 +120,7 @@ class ElementAttributes { } get bindings(): ReadonlyArray { - return this.byKind.get(ir.BindingKind.Property) ?? FLYWEIGHT_ARRAY; + return this.propertyBindings ?? FLYWEIGHT_ARRAY; } get template(): ReadonlyArray { @@ -128,7 +133,7 @@ class ElementAttributes { constructor(private compatibility: ir.CompatibilityMode) {} - isKnown(kind: ir.BindingKind, name: string, value: o.Expression|null) { + private isKnown(kind: ir.BindingKind, name: string, value: o.Expression|null) { const nameToValue = this.known.get(kind) ?? new Set(); this.known.set(kind, nameToValue); if (nameToValue.has(name)) { @@ -182,10 +187,15 @@ class ElementAttributes { } private arrayFor(kind: ir.BindingKind): o.Expression[] { - if (!this.byKind.has(kind)) { - this.byKind.set(kind, []); + if (kind === ir.BindingKind.Property || kind === ir.BindingKind.TwoWayProperty) { + this.propertyBindings ??= []; + return this.propertyBindings; + } else { + if (!this.byKind.has(kind)) { + this.byKind.set(kind, []); + } + return this.byKind.get(kind)!; } - return this.byKind.get(kind)!; } } diff --git a/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts b/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts index ee795f73e32f..86b87a696f4b 100644 --- a/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts +++ b/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts @@ -54,6 +54,7 @@ function recursivelyProcessView(view: ViewCompilationUnit, parentScope: Scope|nu } break; case ir.OpKind.Listener: + case ir.OpKind.TwoWayListener: // Prepend variables to listener handler functions. op.handlerOps.prepend(generateVariablesInScopeForView(view, scope)); break; diff --git a/packages/compiler/src/template/pipeline/src/phases/naming.ts b/packages/compiler/src/template/pipeline/src/phases/naming.ts index 6b06b3c0a3bc..0cedd032fa1e 100644 --- a/packages/compiler/src/template/pipeline/src/phases/naming.ts +++ b/packages/compiler/src/template/pipeline/src/phases/naming.ts @@ -61,6 +61,16 @@ function addNamesToView( } op.handlerFnName = sanitizeIdentifier(op.handlerFnName); break; + case ir.OpKind.TwoWayListener: + if (op.handlerFnName !== null) { + break; + } + if (op.targetSlot.slot === null) { + throw new Error(`Expected a slot to be assigned`); + } + op.handlerFnName = sanitizeIdentifier(`${unit.fnName}_${op.tag!.replace('-', '_')}_${ + op.name}_${op.targetSlot.slot}_listener`); + break; case ir.OpKind.Variable: varNames.set(op.xref, getVariableName(unit, op.variable, state)); break; diff --git a/packages/compiler/src/template/pipeline/src/phases/next_context_merging.ts b/packages/compiler/src/template/pipeline/src/phases/next_context_merging.ts index fc8614104fc6..91d3fd6a260e 100644 --- a/packages/compiler/src/template/pipeline/src/phases/next_context_merging.ts +++ b/packages/compiler/src/template/pipeline/src/phases/next_context_merging.ts @@ -26,7 +26,7 @@ import type {CompilationJob} from '../compilation'; export function mergeNextContextExpressions(job: CompilationJob): void { for (const unit of job.units) { for (const op of unit.create) { - if (op.kind === ir.OpKind.Listener) { + if (op.kind === ir.OpKind.Listener || op.kind === ir.OpKind.TwoWayListener) { mergeNextContextsInOps(op.handlerOps); } } diff --git a/packages/compiler/src/template/pipeline/src/phases/ordering.ts b/packages/compiler/src/template/pipeline/src/phases/ordering.ts index 5dac24695059..fe669df8b642 100644 --- a/packages/compiler/src/template/pipeline/src/phases/ordering.ts +++ b/packages/compiler/src/template/pipeline/src/phases/ordering.ts @@ -21,6 +21,16 @@ function kindWithInterpolationTest( }; } +function basicListenerKindTest(op: ir.CreateOp): boolean { + return (op.kind === ir.OpKind.Listener && !(op.hostListener && op.isAnimationListener)) || + op.kind === ir.OpKind.TwoWayListener; +} + +function nonInterpolationPropertyKindTest(op: ir.UpdateOp): boolean { + return (op.kind === ir.OpKind.Property || op.kind === ir.OpKind.TwoWayProperty) && + !(op.expression instanceof ir.Interpolation); +} + interface Rule { test: (op: T) => boolean; transform?: (ops: Array) => Array; @@ -33,7 +43,7 @@ interface Rule { */ const CREATE_ORDERING: Array> = [ {test: op => op.kind === ir.OpKind.Listener && op.hostListener && op.isAnimationListener}, - {test: op => op.kind === ir.OpKind.Listener && !(op.hostListener && op.isAnimationListener)}, + {test: basicListenerKindTest}, ]; /** @@ -47,7 +57,7 @@ const UPDATE_ORDERING: Array> = [ {test: kindTest(ir.OpKind.ClassProp)}, {test: kindWithInterpolationTest(ir.OpKind.Attribute, true)}, {test: kindWithInterpolationTest(ir.OpKind.Property, true)}, - {test: kindWithInterpolationTest(ir.OpKind.Property, false)}, + {test: nonInterpolationPropertyKindTest}, {test: kindWithInterpolationTest(ir.OpKind.Attribute, false)}, ]; @@ -68,8 +78,9 @@ const UPDATE_HOST_ORDERING: Array> = [ * The set of all op kinds we handle in the reordering phase. */ const handledOpKinds = new Set([ - ir.OpKind.Listener, ir.OpKind.StyleMap, ir.OpKind.ClassMap, ir.OpKind.StyleProp, - ir.OpKind.ClassProp, ir.OpKind.Property, ir.OpKind.HostProperty, ir.OpKind.Attribute + ir.OpKind.Listener, ir.OpKind.TwoWayListener, ir.OpKind.StyleMap, ir.OpKind.ClassMap, + ir.OpKind.StyleProp, ir.OpKind.ClassProp, ir.OpKind.Property, ir.OpKind.TwoWayProperty, + ir.OpKind.HostProperty, ir.OpKind.Attribute ]); /** diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index 0d556b084d38..a3fc16353dd0 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -164,6 +164,13 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList|ir.OpList): void { for (const op of ops) { - if (op.kind === ir.OpKind.Listener) { + if (op.kind === ir.OpKind.Listener || op.kind === ir.OpKind.TwoWayListener) { ir.transformExpressionsInOp(op, (expr) => { if (expr instanceof ir.LexicalReadExpr && expr.name === '$event') { - op.consumesDollarEvent = true; + // Two-way listeners always consume `$event` so they omit this field. + if (op.kind === ir.OpKind.Listener) { + op.consumesDollarEvent = true; + } return new o.ReadVarExpr(expr.name); } return expr; diff --git a/packages/compiler/src/template/pipeline/src/phases/resolve_names.ts b/packages/compiler/src/template/pipeline/src/phases/resolve_names.ts index c1efb510c436..6d988c205321 100644 --- a/packages/compiler/src/template/pipeline/src/phases/resolve_names.ts +++ b/packages/compiler/src/template/pipeline/src/phases/resolve_names.ts @@ -60,6 +60,7 @@ function processLexicalScope( } break; case ir.OpKind.Listener: + case ir.OpKind.TwoWayListener: // Listener functions have separate variable declarations, so process them as a separate // lexical scope. processLexicalScope(unit, op.handlerOps, savedView); @@ -71,7 +72,7 @@ function processLexicalScope( // scope. Also, look for `ir.RestoreViewExpr`s and match them with the snapshotted view context // variable. for (const op of ops) { - if (op.kind == ir.OpKind.Listener) { + if (op.kind == ir.OpKind.Listener || op.kind === ir.OpKind.TwoWayListener) { // Listeners were already processed above with their own scopes. continue; } diff --git a/packages/compiler/src/template/pipeline/src/phases/save_restore_view.ts b/packages/compiler/src/template/pipeline/src/phases/save_restore_view.ts index 9c67f1abef76..55f378544e5e 100644 --- a/packages/compiler/src/template/pipeline/src/phases/save_restore_view.ts +++ b/packages/compiler/src/template/pipeline/src/phases/save_restore_view.ts @@ -28,7 +28,7 @@ export function saveAndRestoreView(job: ComponentCompilationJob): void { ]); for (const op of unit.create) { - if (op.kind !== ir.OpKind.Listener) { + if (op.kind !== ir.OpKind.Listener && op.kind !== ir.OpKind.TwoWayListener) { continue; } @@ -53,7 +53,8 @@ export function saveAndRestoreView(job: ComponentCompilationJob): void { } } -function addSaveRestoreViewOperationToListener(unit: ViewCompilationUnit, op: ir.ListenerOp) { +function addSaveRestoreViewOperationToListener( + unit: ViewCompilationUnit, op: ir.ListenerOp|ir.TwoWayListenerOp) { op.handlerOps.prepend([ ir.createVariableOp( unit.job.allocateXrefId(), { diff --git a/packages/compiler/src/template/pipeline/src/phases/temporary_variables.ts b/packages/compiler/src/template/pipeline/src/phases/temporary_variables.ts index e581c97a960b..a0605f62510b 100644 --- a/packages/compiler/src/template/pipeline/src/phases/temporary_variables.ts +++ b/packages/compiler/src/template/pipeline/src/phases/temporary_variables.ts @@ -78,7 +78,7 @@ function generateTemporaries(ops: ir.OpList): .map(name => ir.createStatementOp(new o.DeclareVarStmt(name)))); opCount++; - if (op.kind === ir.OpKind.Listener) { + if (op.kind === ir.OpKind.Listener || op.kind === ir.OpKind.TwoWayListener) { op.handlerOps.prepend(generateTemporaries(op.handlerOps) as ir.UpdateOp[]); } } diff --git a/packages/compiler/src/template/pipeline/src/phases/transform_two_way_binding_set.ts b/packages/compiler/src/template/pipeline/src/phases/transform_two_way_binding_set.ts new file mode 100644 index 000000000000..f94dbbe8642f --- /dev/null +++ b/packages/compiler/src/template/pipeline/src/phases/transform_two_way_binding_set.ts @@ -0,0 +1,90 @@ +/** + * @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 * as o from '../../../../output/output_ast'; +import * as ir from '../../ir'; +import * as ng from '../instruction'; +import type {CompilationJob} from '../compilation'; + +/** + * Transforms a `TwoWayBindingSet` expression into an expression that either + * sets a value through the `twoWayBindingSet` instruction or falls back to setting + * the value directly. E.g. the expression `TwoWayBindingSet(target, value)` becomes: + * `ng.twoWayBindingSet(target, value) || (target = value)`. + */ +export function transformTwoWayBindingSet(job: CompilationJob): void { + for (const unit of job.units) { + for (const op of unit.create) { + if (op.kind === ir.OpKind.TwoWayListener) { + ir.transformExpressionsInOp(op, (expr) => { + if (expr instanceof ir.TwoWayBindingSetExpr) { + return wrapAction(expr.target, expr.value); + } + return expr; + }, ir.VisitorContextFlag.InChildOperation); + } + } + } +} + +function wrapSetOperation(target: o.ReadPropExpr|o.ReadKeyExpr, value: o.Expression): o.Expression { + return ng.twoWayBindingSet(target, value).or(target.set(value)); +} + +function isReadExpression(value: unknown): value is o.ReadPropExpr|o.ReadKeyExpr { + return value instanceof o.ReadPropExpr || value instanceof o.ReadKeyExpr; +} + +function wrapAction(target: o.Expression, value: o.Expression): o.Expression { + // The only officially supported expressions inside of a two-way binding are read expressions. + if (isReadExpression(target)) { + return wrapSetOperation(target, value); + } + + // However, historically the expression parser was handling two-way events by appending `=$event` + // to the raw string before attempting to parse it. This has led to bugs over the years (see + // #37809) and to unintentionally supporting unassignable events in the two-way binding. The + // logic below aims to emulate the old behavior while still supporting the new output format + // which uses `twoWayBindingSet`. Note that the generated code doesn't necessarily make sense + // based on what the user wrote, for example the event binding for `[(value)]="a ? b : c"` + // would produce `ctx.a ? ctx.b : ctx.c = $event`. We aim to reproduce what the parser used + // to generate before #54154. + if (target instanceof o.BinaryOperatorExpr && isReadExpression(target.rhs)) { + // `a && b` -> `ctx.a && twoWayBindingSet(ctx.b, $event) || (ctx.b = $event)` + return new o.BinaryOperatorExpr( + target.operator, target.lhs, wrapSetOperation(target.rhs, value)); + } + + // Note: this also supports nullish coalescing expressions which + // would've been downleveled to ternary expressions by this point. + if (target instanceof o.ConditionalExpr && isReadExpression(target.falseCase)) { + // `a ? b : c` -> `ctx.a ? ctx.b : twoWayBindingSet(ctx.c, $event) || (ctx.c = $event)` + return new o.ConditionalExpr( + target.condition, target.trueCase, wrapSetOperation(target.falseCase, value)); + } + + // `!!a` -> `twoWayBindingSet(ctx.a, $event) || (ctx.a = $event)` + // Note: previously we'd actually produce `!!(ctx.a = $event)`, but the wrapping + // node doesn't affect the result so we don't need to carry it over. + if (target instanceof o.NotExpr) { + let expr = target.condition; + + while (true) { + if (expr instanceof o.NotExpr) { + expr = expr.condition; + } else { + if (isReadExpression(expr)) { + return wrapSetOperation(expr, value); + } + break; + } + } + } + + throw new Error(`Unsupported expression in two-way action binding.`); +} diff --git a/packages/compiler/src/template/pipeline/src/phases/var_counting.ts b/packages/compiler/src/template/pipeline/src/phases/var_counting.ts index 0487580216b3..09b06b33c8d6 100644 --- a/packages/compiler/src/template/pipeline/src/phases/var_counting.ts +++ b/packages/compiler/src/template/pipeline/src/phases/var_counting.ts @@ -112,6 +112,9 @@ function varsUsedByOp(op: (ir.CreateOp|ir.UpdateOp)&ir.ConsumesVarsTrait): numbe slots += op.expression.expressions.length; } return slots; + case ir.OpKind.TwoWayProperty: + // Two-way properties can only have expressions so they only need one variable slot. + return 1; case ir.OpKind.StyleProp: case ir.OpKind.ClassProp: case ir.OpKind.StyleMap: diff --git a/packages/compiler/src/template/pipeline/src/phases/variable_optimization.ts b/packages/compiler/src/template/pipeline/src/phases/variable_optimization.ts index 48e9b622d325..9255ef708759 100644 --- a/packages/compiler/src/template/pipeline/src/phases/variable_optimization.ts +++ b/packages/compiler/src/template/pipeline/src/phases/variable_optimization.ts @@ -34,7 +34,7 @@ export function optimizeVariables(job: CompilationJob): void { inlineAlwaysInlineVariables(unit.update); for (const op of unit.create) { - if (op.kind === ir.OpKind.Listener) { + if (op.kind === ir.OpKind.Listener || op.kind === ir.OpKind.TwoWayListener) { inlineAlwaysInlineVariables(op.handlerOps); } } @@ -43,7 +43,7 @@ export function optimizeVariables(job: CompilationJob): void { optimizeVariablesInOpList(unit.update, job.compatibility); for (const op of unit.create) { - if (op.kind === ir.OpKind.Listener) { + if (op.kind === ir.OpKind.Listener || op.kind === ir.OpKind.TwoWayListener) { optimizeVariablesInOpList(op.handlerOps, job.compatibility); } } diff --git a/packages/compiler/src/template_parser/binding_parser.ts b/packages/compiler/src/template_parser/binding_parser.ts index 3ad4782ea111..6d11c640ffd7 100644 --- a/packages/compiler/src/template_parser/binding_parser.ts +++ b/packages/compiler/src/template_parser/binding_parser.ts @@ -7,7 +7,7 @@ */ import {SecurityContext} from '../core'; -import {AbsoluteSourceSpan, ASTWithSource, BindingPipe, BindingType, BoundElementProperty, EmptyExpr, ParsedEvent, ParsedEventType, ParsedProperty, ParsedPropertyType, ParsedVariable, ParserError, RecursiveAstVisitor, TemplateBinding, VariableBinding} from '../expression_parser/ast'; +import {AbsoluteSourceSpan, AST, ASTWithSource, Binary, BindingPipe, BindingType, BoundElementProperty, Conditional, EmptyExpr, KeyedRead, NonNullAssert, ParsedEvent, ParsedEventType, ParsedProperty, ParsedPropertyType, ParsedVariable, ParserError, PrefixNot, PropertyRead, RecursiveAstVisitor, TemplateBinding, VariableBinding} from '../expression_parser/ast'; import {Parser} from '../expression_parser/parser'; import {InterpolationConfig} from '../ml_parser/defaults'; import {mergeNsAndName} from '../ml_parser/tags'; @@ -418,8 +418,7 @@ export class BindingParser { keySpan = moveParseSourceSpan( keySpan, new AbsoluteSourceSpan(keySpan.start.offset + 1, keySpan.end.offset)); } - this._parseAnimationEvent( - name, expression, isAssignmentEvent, sourceSpan, handlerSpan, targetEvents, keySpan); + this._parseAnimationEvent(name, expression, sourceSpan, handlerSpan, targetEvents, keySpan); } else { this._parseRegularEvent( name, expression, isAssignmentEvent, sourceSpan, handlerSpan, targetMatchableAttrs, @@ -434,12 +433,12 @@ export class BindingParser { } private _parseAnimationEvent( - name: string, expression: string, isAssignmentEvent: boolean, sourceSpan: ParseSourceSpan, - handlerSpan: ParseSourceSpan, targetEvents: ParsedEvent[], keySpan: ParseSourceSpan) { + name: string, expression: string, sourceSpan: ParseSourceSpan, handlerSpan: ParseSourceSpan, + targetEvents: ParsedEvent[], keySpan: ParseSourceSpan) { const matches = splitAtPeriod(name, [name, '']); const eventName = matches[0]; const phase = matches[1].toLowerCase(); - const ast = this._parseAction(expression, isAssignmentEvent, handlerSpan); + const ast = this._parseAction(expression, handlerSpan); targetEvents.push(new ParsedEvent( eventName, phase, ParsedEventType.Animation, ast, sourceSpan, handlerSpan, keySpan)); @@ -464,11 +463,20 @@ export class BindingParser { private _parseRegularEvent( name: string, expression: string, isAssignmentEvent: boolean, sourceSpan: ParseSourceSpan, handlerSpan: ParseSourceSpan, targetMatchableAttrs: string[][], targetEvents: ParsedEvent[], - keySpan: ParseSourceSpan) { + keySpan: ParseSourceSpan): void { // long format: 'target: eventName' const [target, eventName] = splitAtColon(name, [null!, name]); - const ast = this._parseAction(expression, isAssignmentEvent, handlerSpan); + const prevErrorCount = this.errors.length; + const ast = this._parseAction(expression, handlerSpan); + const isValid = this.errors.length === prevErrorCount; targetMatchableAttrs.push([name!, ast.source!]); + + // Don't try to validate assignment events if there were other + // parsing errors to avoid adding more noise to the error logs. + if (isAssignmentEvent && isValid && !this._isAllowedAssignmentEvent(ast)) { + this._reportError('Unsupported expression in a two-way binding', sourceSpan); + } + targetEvents.push(new ParsedEvent( eventName, target, isAssignmentEvent ? ParsedEventType.TwoWay : ParsedEventType.Regular, ast, sourceSpan, handlerSpan, keySpan)); @@ -476,14 +484,13 @@ export class BindingParser { // so don't add the event name to the matchableAttrs } - private _parseAction(value: string, isAssignmentEvent: boolean, sourceSpan: ParseSourceSpan): - ASTWithSource { + private _parseAction(value: string, sourceSpan: ParseSourceSpan): ASTWithSource { const sourceInfo = (sourceSpan && sourceSpan.start || '(unknown').toString(); const absoluteOffset = (sourceSpan && sourceSpan.start) ? sourceSpan.start.offset : 0; try { const ast = this._exprParser.parseAction( - value, isAssignmentEvent, sourceInfo, absoluteOffset, this._interpolationConfig); + value, sourceInfo, absoluteOffset, this._interpolationConfig); if (ast) { this._reportExpressionParserErrors(ast.errors, sourceSpan); } @@ -523,6 +530,31 @@ export class BindingParser { this._reportError(report.msg!, sourceSpan, ParseErrorLevel.ERROR); } } + + /** + * Returns whether a parsed AST is allowed to be used within the event side of a two-way binding. + * @param ast Parsed AST to be checked. + */ + private _isAllowedAssignmentEvent(ast: AST): boolean { + if (ast instanceof ASTWithSource) { + return this._isAllowedAssignmentEvent(ast.ast); + } + + if (ast instanceof NonNullAssert) { + return this._isAllowedAssignmentEvent(ast.expression); + } + + if (ast instanceof PropertyRead || ast instanceof KeyedRead) { + return true; + } + + if (ast instanceof Binary) { + return (ast.operation === '&&' || ast.operation === '||' || ast.operation === '??') && + (ast.right instanceof PropertyRead || ast.right instanceof KeyedRead); + } + + return ast instanceof Conditional || ast instanceof PrefixNot; + } } export class PipeCollector extends RecursiveAstVisitor { diff --git a/packages/compiler/test/expression_parser/parser_spec.ts b/packages/compiler/test/expression_parser/parser_spec.ts index cfc59cda3d30..8e399fecad03 100644 --- a/packages/compiler/test/expression_parser/parser_spec.ts +++ b/packages/compiler/test/expression_parser/parser_spec.ts @@ -1174,7 +1174,7 @@ function createParser() { } function parseAction(text: string, location: any = null, offset: number = 0): ASTWithSource { - return createParser().parseAction(text, /* isAssignmentEvent */ false, location, offset); + return createParser().parseAction(text, location, offset); } function parseBinding(text: string, location: any = null, offset: number = 0): ASTWithSource { diff --git a/packages/compiler/test/render3/r3_template_transform_spec.ts b/packages/compiler/test/render3/r3_template_transform_spec.ts index 5ce14e9145d6..702187e1b64d 100644 --- a/packages/compiler/test/render3/r3_template_transform_spec.ts +++ b/packages/compiler/test/render3/r3_template_transform_spec.ts @@ -496,7 +496,7 @@ describe('R3 template transform', () => { expectFromHtml('
').toEqual([ ['Element', 'div'], ['BoundAttribute', BindingType.TwoWay, 'prop', 'v'], - ['BoundEvent', ParsedEventType.TwoWay, 'propChange', null, 'v = $event'], + ['BoundEvent', ParsedEventType.TwoWay, 'propChange', null, 'v'], ]); }); @@ -504,7 +504,7 @@ describe('R3 template transform', () => { expectFromHtml('
').toEqual([ ['Element', 'div'], ['BoundAttribute', BindingType.TwoWay, 'prop', 'v'], - ['BoundEvent', ParsedEventType.TwoWay, 'propChange', null, 'v = $event'], + ['BoundEvent', ParsedEventType.TwoWay, 'propChange', null, 'v'], ]); }); @@ -512,10 +512,77 @@ describe('R3 template transform', () => { expectFromHtml('
').toEqual([ ['Element', 'div'], ['BoundAttribute', BindingType.TwoWay, 'prop', 'v!'], - ['BoundEvent', ParsedEventType.TwoWay, 'propChange', null, 'v = $event'], + ['BoundEvent', ParsedEventType.TwoWay, 'propChange', null, 'v!'], ]); }); + it('should parse property reads bound via [(...)]', () => { + expectFromHtml('
').toEqual([ + ['Element', 'div'], + ['BoundAttribute', BindingType.TwoWay, 'prop', 'a.b.c'], + ['BoundEvent', ParsedEventType.TwoWay, 'propChange', null, 'a.b.c'], + ]); + }); + + it('should parse keyed reads bound via [(...)]', () => { + expectFromHtml(`
`).toEqual([ + ['Element', 'div'], + ['BoundAttribute', BindingType.TwoWay, 'prop', `a["b"]["c"]`], + ['BoundEvent', ParsedEventType.TwoWay, 'propChange', null, `a["b"]["c"]`], + ]); + }); + + it('should report assignments in two-way bindings', () => { + expect(() => parse(`
`)) + .toThrowError(/Bindings cannot contain assignments/); + }); + + it('should report pipes in two-way bindings', () => { + expect(() => parse(`
`)) + .toThrowError(/Cannot have a pipe in an action expression/); + }); + + it('should report unsupported expressions in two-way bindings', () => { + const unsupportedExpressions = [ + 'v + 1', + 'foo.bar?.baz', + `foo.bar?.['baz']`, + 'true', + '123', + 'a.b()', + 'v()', + '[1, 2, 3]', + '{a: 1, b: 2, c: 3}', + 'v === 1', + ]; + + for (const expression of unsupportedExpressions) { + expect(() => parse(`
`)) + .withContext(expression) + .toThrowError(/Unsupported expression in a two-way binding/); + } + }); + + it('should allow some unassignable expressions in two-way bindings for backwards compatibility', + () => { + const expressions = [ + 'a || b', + 'a && b', + 'a ?? b', + '!a', + '!!a', + 'a ? b : c', + ]; + + for (const expression of expressions) { + expectFromHtml(`
`).toEqual([ + ['Element', 'div'], + ['BoundAttribute', BindingType.TwoWay, 'prop', expression], + ['BoundEvent', ParsedEventType.TwoWay, 'propChange', null, expression], + ]); + } + }); + it('should report an error for assignments into non-null asserted expressions', () => { // TODO(joost): this syntax is allowed in TypeScript. Consider changing the grammar to // allow this syntax, or improve the error message. diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index fe26d431efe6..2e681434c8c9 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -241,6 +241,9 @@ export { ɵɵtextInterpolateV, ɵɵviewQuery, ɵɵviewQuerySignal, + ɵɵtwoWayProperty, + ɵɵtwoWayBindingSet, + ɵɵtwoWayListener, ɵgetUnknownElementStrictMode, ɵsetUnknownElementStrictMode, ɵgetUnknownPropertyStrictMode, diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index 259f3aa5c793..0844c65855f0 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -164,6 +164,11 @@ export { ɵɵtextInterpolate7, ɵɵtextInterpolate8, ɵɵtextInterpolateV, + + ɵɵtwoWayProperty, + ɵɵtwoWayBindingSet, + ɵɵtwoWayListener, + ɵgetUnknownElementStrictMode, ɵsetUnknownElementStrictMode, ɵgetUnknownPropertyStrictMode, diff --git a/packages/core/src/render3/instructions/all.ts b/packages/core/src/render3/instructions/all.ts index ffdbef60fe9b..8eee3b76de8b 100644 --- a/packages/core/src/render3/instructions/all.ts +++ b/packages/core/src/render3/instructions/all.ts @@ -56,3 +56,4 @@ export * from './styling'; export * from './template'; export * from './text'; export * from './text_interpolation'; +export * from './two_way'; diff --git a/packages/core/src/render3/instructions/two_way.ts b/packages/core/src/render3/instructions/two_way.ts new file mode 100644 index 000000000000..d644b8548bf6 --- /dev/null +++ b/packages/core/src/render3/instructions/two_way.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 {SanitizerFn} from '../interfaces/sanitization'; + +import {ɵɵlistener} from './listener'; +import {ɵɵproperty} from './property'; + + +/** + * Update a two-way bound property on a selected element. + * + * Operates on the element selected by index via the {@link select} instruction. + * + * @param propName Name of property. + * @param value New value to write. + * @param sanitizer An optional function used to sanitize the value. + * @returns This function returns itself so that it may be chained + * (e.g. `twoWayProperty('name', ctx.name)('title', ctx.title)`) + * + * @codeGenApi + */ +export function ɵɵtwoWayProperty( + propName: string, value: T, sanitizer?: SanitizerFn|null): typeof ɵɵtwoWayProperty { + // TODO(crisbeto): implement two-way specific logic. + ɵɵproperty(propName, value, sanitizer); + return ɵɵtwoWayProperty; +} + +/** + * Function used inside two-way listeners to conditionally set the value of the bound expression. + * + * @param target Field on which to set the value. + * @param value Value to be set to the field. + * + * @codeGenApi + */ +export function ɵɵtwoWayBindingSet(target: unknown, value: T): boolean { + // TODO(crisbeto): implement this fully. + return false; +} + +/** + * Adds an event listener that updates a two-way binding to the current node. + * + * @param eventName Name of the event. + * @param listenerFn The function to be called when event emits. + * + * @codeGenApi + */ +export function ɵɵtwoWayListener( + eventName: string, listenerFn: (e?: any) => any): typeof ɵɵtwoWayListener { + ɵɵlistener(eventName, listenerFn); + return ɵɵtwoWayListener; +} diff --git a/packages/core/src/render3/jit/environment.ts b/packages/core/src/render3/jit/environment.ts index 2163616bd12c..a38119d58701 100644 --- a/packages/core/src/render3/jit/environment.ts +++ b/packages/core/src/render3/jit/environment.ts @@ -204,5 +204,9 @@ export const angularCoreEnv: {[name: string]: unknown} = 'forwardRef': forwardRef, 'resolveForwardRef': resolveForwardRef, + 'ɵɵtwoWayProperty': r3.ɵɵtwoWayProperty, + 'ɵɵtwoWayBindingSet': r3.ɵɵtwoWayBindingSet, + 'ɵɵtwoWayListener': r3.ɵɵtwoWayListener, + 'ɵɵInputFlags': InputFlags, }))(); diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index b160de89ecac..012970877cb7 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -1856,6 +1856,9 @@ { "name": "init_trusted_types_bypass" }, + { + "name": "init_two_way" + }, { "name": "init_type" }, diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 56fe1b61ef68..933db17aef06 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -1777,5 +1777,11 @@ }, { "name": "ɵɵtext" + }, + { + "name": "ɵɵtwoWayListener" + }, + { + "name": "ɵɵtwoWayProperty" } ] \ No newline at end of file