From ca39d89527ce076394dcfae9397bb6c11e8e1c7c Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 29 Jan 2024 10:32:56 +0100 Subject: [PATCH 1/9] refactor(compiler): implement two-way property instruction Reworks the compiler so that it generates a `twoWayProperty` instruction, instead of `property`, for the property side of a two-way binding. Currently the new instruction passes through to `property`, but it'll have some two-way-binding-specific logic in subsequent PRs. --- .../nested_two_way_template.js | 18 ++--- .../simple_two_way_template.js | 12 +-- .../compiler/src/render3/r3_identifiers.ts | 3 + .../compiler/src/render3/view/template.ts | 15 +++- packages/compiler/src/render3/view/util.ts | 1 + .../src/template/pipeline/ir/src/enums.ts | 10 +++ .../template/pipeline/ir/src/expression.ts | 5 ++ .../template/pipeline/ir/src/ops/update.ts | 75 ++++++++++++++++++- .../src/template/pipeline/src/ingest.ts | 28 ++++--- .../src/template/pipeline/src/instruction.ts | 10 +++ .../src/phases/attribute_extraction.ts | 8 ++ .../src/phases/binding_specialization.ts | 17 +++++ .../template/pipeline/src/phases/chaining.ts | 1 + .../pipeline/src/phases/const_collection.ts | 22 ++++-- .../src/template/pipeline/src/phases/reify.ts | 4 + .../pipeline/src/phases/var_counting.ts | 3 + .../core/src/core_render3_private_export.ts | 1 + packages/core/src/render3/index.ts | 3 + packages/core/src/render3/instructions/all.ts | 1 + .../core/src/render3/instructions/two_way.ts | 32 ++++++++ packages/core/src/render3/jit/environment.ts | 2 + 21 files changed, 237 insertions(+), 34 deletions(-) create mode 100644 packages/core/src/render3/instructions/two_way.ts 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..b3c779e9fabf 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,15 @@ 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$.ɵɵlistener("ngModelChange", function TestCmp_ng_template_1_Template_input_ngModelChange_0_listener($event) { + $r3$.ɵɵrestoreView($_r2$); + const $ctx_r1$ = $r3$.ɵɵnextContext(); + return $r3$.ɵɵresetView($ctx_r1$.name = $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..6d5f3c056b05 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,13 @@ 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) { + $r3$.ɵɵtext(0, "Name: "); + $r3$.ɵɵelementStart(1, "input", 0); + $r3$.ɵɵlistener("ngModelChange", function TestCmp_Template_input_ngModelChange_1_listener($event) { return ctx.name = $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/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index db066b4117fa..b4f43e78146c 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -355,6 +355,9 @@ 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 NgOnChangesFeature: o.ExternalReference = {name: 'ɵɵNgOnChangesFeature', moduleName: CORE}; static InheritDefinitionFeature: diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index 38d4986b3a54..5e516005884c 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -805,6 +805,7 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver // TODO (matsko): revisit this once FW-959 is approached const emptyValueBindInstruction = o.literal(undefined); const propertyBindings: Omit[] = []; + const twoWayBindings: Omit[] = []; const attributeBindings: Omit[] = []; // Generate element input bindings @@ -866,7 +867,7 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver } this.allocateBindingSlots(value); - if (inputType === BindingType.Property || inputType === BindingType.TwoWay) { + if (inputType === BindingType.Property) { if (value instanceof Interpolation) { // prop="{{value}}" and friends this.interpolatedUpdateInstruction( @@ -881,6 +882,13 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver () => this.convertPropertyBinding(value), attrName, params) }); } + } else if (inputType === BindingType.TwoWay) { + // Property side of a two-way binding expression (e.g. `[(prop)]="value"`). + twoWayBindings.push({ + span: input.sourceSpan, + paramsOrFn: getBindingFunctionParams( + () => this.convertPropertyBinding(value), attrName, params) + }); } else if (inputType === BindingType.Attribute) { if (value instanceof Interpolation && getInterpolationArgsLength(value) > 1) { // attr.name="text{{value}}" and friends @@ -915,6 +923,11 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver elementIndex, propertyBinding.span, R3.property, propertyBinding.paramsOrFn); } + for (const twoWayBinding of twoWayBindings) { + this.updateInstructionWithAdvance( + elementIndex, twoWayBinding.span, R3.twoWayProperty, twoWayBinding.paramsOrFn); + } + for (const attributeBinding of attributeBindings) { this.updateInstructionWithAdvance( elementIndex, attributeBinding.span, R3.attribute, attributeBinding.paramsOrFn); diff --git a/packages/compiler/src/render3/view/util.ts b/packages/compiler/src/render3/view/util.ts index 4d80e1e9ede6..f76393b23770 100644 --- a/packages/compiler/src/render3/view/util.ts +++ b/packages/compiler/src/render3/view/util.ts @@ -114,6 +114,7 @@ const CHAINABLE_INSTRUCTIONS = new Set([ R3.textInterpolate8, R3.textInterpolateV, R3.templateCreate, + R3.twoWayProperty, ]); /** diff --git a/packages/compiler/src/template/pipeline/ir/src/enums.ts b/packages/compiler/src/template/pipeline/ir/src/enums.ts index 05416543b8d9..48f8ba27dda1 100644 --- a/packages/compiler/src/template/pipeline/ir/src/enums.ts +++ b/packages/compiler/src/template/pipeline/ir/src/enums.ts @@ -195,6 +195,11 @@ export enum OpKind { */ Repeater, + /** + * An operation to bind an expression to the property side of a two-way binding. + */ + TwoWayProperty, + /** * The start of an i18n block. */ @@ -474,6 +479,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..4c7347bc69b5 100644 --- a/packages/compiler/src/template/pipeline/ir/src/expression.ts +++ b/packages/compiler/src/template/pipeline/ir/src/expression.ts @@ -884,6 +884,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; 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/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index d92b1aef90d7..a3c887017607 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], @@ -1043,15 +1042,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)) { diff --git a/packages/compiler/src/template/pipeline/src/instruction.ts b/packages/compiler/src/template/pipeline/src/instruction.ts index 5026a6e0fd63..a20f29c08d00 100644 --- a/packages/compiler/src/template/pipeline/src/instruction.ts +++ b/packages/compiler/src/template/pipeline/src/instruction.ts @@ -325,6 +325,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..7ba53156e6e3 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? 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..8aec0fb76e7c 100644 --- a/packages/compiler/src/template/pipeline/src/phases/chaining.ts +++ b/packages/compiler/src/template/pipeline/src/phases/chaining.ts @@ -38,6 +38,7 @@ const CHAINABLE = new Set([ R3.syntheticHostListener, R3.syntheticHostProperty, R3.templateCreate, + R3.twoWayProperty, ]); /** 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/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index 0d556b084d38..2519914be32d 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -300,6 +300,10 @@ function reifyUpdateOperations(_unit: CompilationUnit, ops: ir.OpList( + propName: string, value: T, sanitizer?: SanitizerFn|null): typeof ɵɵtwoWayProperty { + // TODO(crisbeto): implement two-way specific logic. + ɵɵproperty(propName, value, sanitizer); + return ɵɵtwoWayProperty; +} diff --git a/packages/core/src/render3/jit/environment.ts b/packages/core/src/render3/jit/environment.ts index 2163616bd12c..8119574f743e 100644 --- a/packages/core/src/render3/jit/environment.ts +++ b/packages/core/src/render3/jit/environment.ts @@ -204,5 +204,7 @@ export const angularCoreEnv: {[name: string]: unknown} = 'forwardRef': forwardRef, 'resolveForwardRef': resolveForwardRef, + 'ɵɵtwoWayProperty': r3.ɵɵtwoWayProperty, + 'ɵɵInputFlags': InputFlags, }))(); From 4ae881f56a17f5ae144ed67d5dc5967289f9e698 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 29 Jan 2024 14:27:07 +0100 Subject: [PATCH 2/9] refactor(compiler): update access of members in expression parser Currently all the members of `_ParseAST` are public, even though they're all used only within the class. This change marks them as private so that it's explicit which ones are intended to be used outside the class. --- .../compiler/src/expression_parser/parser.ts | 89 ++++++++++--------- 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/packages/compiler/src/expression_parser/parser.ts b/packages/compiler/src/expression_parser/parser.ts index a60c08efd84c..f0458a7820f7 100644 --- a/packages/compiler/src/expression_parser/parser.ts +++ b/packages/compiler/src/expression_parser/parser.ts @@ -382,7 +382,7 @@ enum ParseContextFlags { Writable = 1, } -export class _ParseAST { +class _ParseAST { private rparensExpected = 0; private rbracketsExpected = 0; private rbracesExpected = 0; @@ -394,24 +394,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 +419,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 +427,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 +443,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 +455,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 +476,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 +485,7 @@ export class _ParseAST { return this.sourceSpanCache.get(serial)!; } - advance() { + private advance() { this.index++; } @@ -499,7 +499,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 +508,11 @@ export class _ParseAST { } } - peekKeywordLet(): boolean { + private peekKeywordLet(): boolean { return this.next.isKeywordLet(); } - peekKeywordAs(): boolean { + + private peekKeywordAs(): boolean { return this.next.isKeywordAs(); } @@ -521,12 +522,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 +536,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 +559,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 +611,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 +660,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 +685,7 @@ export class _ParseAST { } } - parseLogicalOr(): AST { + private parseLogicalOr(): AST { // '||' const start = this.inputIndex; let result = this.parseLogicalAnd(); @@ -695,7 +696,7 @@ export class _ParseAST { return result; } - parseLogicalAnd(): AST { + private parseLogicalAnd(): AST { // '&&' const start = this.inputIndex; let result = this.parseNullishCoalescing(); @@ -706,7 +707,7 @@ export class _ParseAST { return result; } - parseNullishCoalescing(): AST { + private parseNullishCoalescing(): AST { // '??' const start = this.inputIndex; let result = this.parseEquality(); @@ -717,7 +718,7 @@ export class _ParseAST { return result; } - parseEquality(): AST { + private parseEquality(): AST { // '==','!=','===','!==' const start = this.inputIndex; let result = this.parseRelational(); @@ -738,7 +739,7 @@ export class _ParseAST { return result; } - parseRelational(): AST { + private parseRelational(): AST { // '<', '>', '<=', '>=' const start = this.inputIndex; let result = this.parseAdditive(); @@ -759,7 +760,7 @@ export class _ParseAST { return result; } - parseAdditive(): AST { + private parseAdditive(): AST { // '+', '-' const start = this.inputIndex; let result = this.parseMultiplicative(); @@ -778,7 +779,7 @@ export class _ParseAST { return result; } - parseMultiplicative(): AST { + private parseMultiplicative(): AST { // '*', '%', '/' const start = this.inputIndex; let result = this.parsePrefix(); @@ -798,7 +799,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 +822,7 @@ export class _ParseAST { return this.parseCallChain(); } - parseCallChain(): AST { + private parseCallChain(): AST { const start = this.inputIndex; let result = this.parsePrimary(); while (true) { @@ -848,7 +849,7 @@ export class _ParseAST { } } - parsePrimary(): AST { + private parsePrimary(): AST { const start = this.inputIndex; if (this.consumeOptionalCharacter(chars.$LPAREN)) { this.rparensExpected++; @@ -912,7 +913,7 @@ export class _ParseAST { } } - parseExpressionList(terminator: number): AST[] { + private parseExpressionList(terminator: number): AST[] { const result: AST[] = []; do { @@ -925,7 +926,7 @@ export class _ParseAST { return result; } - parseLiteralMap(): LiteralMap { + private parseLiteralMap(): LiteralMap { const keys: LiteralMapKey[] = []; const values: AST[] = []; const start = this.inputIndex; @@ -958,7 +959,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() ?? ''; @@ -997,7 +998,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(); @@ -1028,7 +1029,7 @@ export class _ParseAST { return this.consumeOptionalOperator('='); } - parseCallArguments(): BindingPipe[] { + private parseCallArguments(): BindingPipe[] { if (this.next.isCharacter(chars.$RPAREN)) return []; const positionals: AST[] = []; do { @@ -1041,7 +1042,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 +1118,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 +1259,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(); } From a0de5b43e25a44501f6d14301945708bdfe393e7 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 29 Jan 2024 14:30:55 +0100 Subject: [PATCH 3/9] refactor(core): introduce two-way listener instructions Adds the following new instructions: * `twoWayBindingSet` - used to assign values inside of the listener side of a two-way binding. Currently a noop, but will come into play later. * `twoWayListener` - used to bind a two-way listener. Currently calls directly into `listener`, but it may be useful in the future. --- .../compiler/src/render3/r3_identifiers.ts | 2 ++ packages/compiler/src/render3/view/util.ts | 1 + .../template/pipeline/src/phases/chaining.ts | 1 + .../core/src/core_render3_private_export.ts | 2 ++ packages/core/src/render3/index.ts | 2 ++ .../core/src/render3/instructions/two_way.ts | 28 +++++++++++++++++++ packages/core/src/render3/jit/environment.ts | 2 ++ 7 files changed, 38 insertions(+) diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index b4f43e78146c..c46d8eca4724 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -357,6 +357,8 @@ export class Identifiers { // 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}; diff --git a/packages/compiler/src/render3/view/util.ts b/packages/compiler/src/render3/view/util.ts index f76393b23770..476f2f59d87f 100644 --- a/packages/compiler/src/render3/view/util.ts +++ b/packages/compiler/src/render3/view/util.ts @@ -115,6 +115,7 @@ const CHAINABLE_INSTRUCTIONS = new Set([ R3.textInterpolateV, R3.templateCreate, R3.twoWayProperty, + R3.twoWayListener, ]); /** diff --git a/packages/compiler/src/template/pipeline/src/phases/chaining.ts b/packages/compiler/src/template/pipeline/src/phases/chaining.ts index 8aec0fb76e7c..f2ba770aec83 100644 --- a/packages/compiler/src/template/pipeline/src/phases/chaining.ts +++ b/packages/compiler/src/template/pipeline/src/phases/chaining.ts @@ -39,6 +39,7 @@ const CHAINABLE = new Set([ R3.syntheticHostProperty, R3.templateCreate, R3.twoWayProperty, + R3.twoWayListener, ]); /** diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index 72a85d19d291..2e681434c8c9 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -242,6 +242,8 @@ export { ɵɵ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 1d41f58dacaf..0844c65855f0 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -166,6 +166,8 @@ export { ɵɵtextInterpolateV, ɵɵtwoWayProperty, + ɵɵtwoWayBindingSet, + ɵɵtwoWayListener, ɵgetUnknownElementStrictMode, ɵsetUnknownElementStrictMode, diff --git a/packages/core/src/render3/instructions/two_way.ts b/packages/core/src/render3/instructions/two_way.ts index c837972c220b..d644b8548bf6 100644 --- a/packages/core/src/render3/instructions/two_way.ts +++ b/packages/core/src/render3/instructions/two_way.ts @@ -8,6 +8,7 @@ import {SanitizerFn} from '../interfaces/sanitization'; +import {ɵɵlistener} from './listener'; import {ɵɵproperty} from './property'; @@ -30,3 +31,30 @@ export function ɵɵtwoWayProperty( ɵɵ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 8119574f743e..a38119d58701 100644 --- a/packages/core/src/render3/jit/environment.ts +++ b/packages/core/src/render3/jit/environment.ts @@ -205,6 +205,8 @@ export const angularCoreEnv: {[name: string]: unknown} = 'resolveForwardRef': resolveForwardRef, 'ɵɵtwoWayProperty': r3.ɵɵtwoWayProperty, + 'ɵɵtwoWayBindingSet': r3.ɵɵtwoWayBindingSet, + 'ɵɵtwoWayListener': r3.ɵɵtwoWayListener, 'ɵɵInputFlags': InputFlags, }))(); From e9c09c92ab61e71fc68ba7157be1461fb67b2e59 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 30 Jan 2024 09:11:30 +0100 Subject: [PATCH 4/9] refactor(compiler): preserve expression in two-way listeners Currently the listener side two-way listeners are parsed by appending `=$event` to the raw expression. This is problematic, because: 1. It can interfere with other expressions (see #37809). 2. It can lead to confusing error messages because users will see code that they didn't write. 3. It doesn't allow us to further manipulate the expression. These changes remove the logic that appends `=$event` to resolve the issue. There's also some new logic that checks the expression after it has been parsed to ensure that the result is an assignable expression. Subsequent commits will update the code that emits the expression to add back the `$event` assignment where it's needed. --- .../compiler/src/expression_parser/ast.ts | 13 ++++- .../compiler/src/expression_parser/parser.ts | 38 ++----------- .../src/render3/r3_template_transform.ts | 2 +- .../src/template_parser/binding_parser.ts | 45 +++++++++++---- .../test/expression_parser/parser_spec.ts | 2 +- .../render3/r3_template_transform_spec.ts | 55 ++++++++++++++++++- 6 files changed, 105 insertions(+), 50 deletions(-) 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 f0458a7820f7..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); } @@ -972,7 +964,7 @@ 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 { @@ -980,7 +972,7 @@ 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)); @@ -1011,24 +1003,6 @@ 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('='); - } - private parseCallArguments(): BindingPipe[] { if (this.next.isCharacter(chars.$RPAREN)) return []; const positionals: AST[] = []; 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/template_parser/binding_parser.ts b/packages/compiler/src/template_parser/binding_parser.ts index 3ad4782ea111..aaf52502398a 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, BindingPipe, BindingType, BoundElementProperty, EmptyExpr, KeyedRead, NonNullAssert, ParsedEvent, ParsedEventType, ParsedProperty, ParsedPropertyType, ParsedVariable, ParserError, 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,22 @@ 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); + } + + return ast instanceof PropertyRead || ast instanceof KeyedRead; + } } 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..e90e2198e13a 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,59 @@ 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', + 'a.b()', + 'v()', + '[1, 2, 3]', + '{a: 1, b: 2, c: 3}', + 'v === 1', + 'a ?? b', + ]; + + for (const expression of unsupportedExpressions) { + expect(() => parse(`
`)) + .withContext(expression) + .toThrowError(/Unsupported expression in a two-way binding/); + } + }); + 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. From 227fafdaedf5b5a28c2888a9c85d8c5a2b4f589a Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 30 Jan 2024 09:41:43 +0100 Subject: [PATCH 5/9] refactor(compiler): update two-way listener emit in definition builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the template definition builder to emit the new format for the listener side of two-way bindings. ```js // Before listener("ngModelChange", function($event) { return ctx.name = $event; }); // After ɵɵtwoWayListener("ngModelChange", function($event) { ɵɵtwoWayBindingSet(ctx.name, $event) || (ctx.name = $event); return $event; }); ``` --- .../nested_two_way_template.js | 5 +- .../simple_two_way_template.js | 5 +- .../two_way_binding_longhand.js | 2 +- .../two_way_binding_longhand_partial.js | 2 +- .../two_way_binding_simple.js | 2 +- .../two_way_binding_simple_partial.js | 2 +- .../test/ngtsc/template_mapping_spec.ts | 4 +- .../src/compiler_util/expression_converter.ts | 92 +++++++++++++------ .../compiler/src/render3/view/template.ts | 18 ++-- .../bundling/defer/bundle.golden_symbols.json | 3 + .../bundle.golden_symbols.json | 6 ++ 11 files changed, 98 insertions(+), 43 deletions(-) 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 b3c779e9fabf..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 @@ -2,10 +2,11 @@ function TestCmp_ng_template_1_Template(rf, ctx) { if (rf & 1) { const $_r2$ = $r3$.ɵɵgetCurrentView(); $r3$.ɵɵelementStart(0, "input", 0); - $r3$.ɵɵlistener("ngModelChange", function TestCmp_ng_template_1_Template_input_ngModelChange_0_listener($event) { + $r3$.ɵɵtwoWayListener("ngModelChange", function TestCmp_ng_template_1_Template_input_ngModelChange_0_listener($event) { $r3$.ɵɵrestoreView($_r2$); const $ctx_r1$ = $r3$.ɵɵnextContext(); - return $r3$.ɵɵresetView($ctx_r1$.name = $event); + $r3$.ɵɵtwoWayBindingSet($ctx_r1$.name, $event) || ($ctx_r1$.name = $event); + return $r3$.ɵɵresetView($event); }); $r3$.ɵɵelementEnd(); } if (rf & 2) { 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 6d5f3c056b05..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 @@ -2,8 +2,9 @@ function TestCmp_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵtext(0, "Name: "); $r3$.ɵɵelementStart(1, "input", 0); - $r3$.ɵɵlistener("ngModelChange", function TestCmp_Template_input_ngModelChange_1_listener($event) { - return ctx.name = $event; + $r3$.ɵɵtwoWayListener("ngModelChange", function TestCmp_Template_input_ngModelChange_1_listener($event) { + $r3$.ɵɵtwoWayBindingSet(ctx.name, $event) || (ctx.name = $event); + return $event; }); $r3$.ɵɵelementEnd(); } if (rf & 2) { 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" " { 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..ed32975673ea 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,43 @@ 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) || + (!(convertedAction.expr instanceof o.ReadPropExpr) && + !(convertedAction.expr instanceof o.ReadKeyExpr))) { + throw new Error(`Illegal state: unsupported expression in two-way action binding.`); + } + + // Converts `[(ngModel)]="name"` to `twoWayBindingSet(ctx.name, $event) || (ctx.name = $event)`. + convertedAction = new o.ExternalExpr(R3.twoWayBindingSet) + .callFn([convertedAction.expr, EventHandlerVars.event]) + .or(convertedAction.expr.set(EventHandlerVars.event)) + .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; +} + export interface BuiltinConverter { (args: o.Expression[]): o.Expression; } @@ -190,6 +203,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/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index 5e516005884c..e7943a20086f 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)); } } @@ -1061,7 +1066,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/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 From e456d67f619bc15f51317bf89d0ed73569f70bdc Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 30 Jan 2024 11:41:20 +0100 Subject: [PATCH 6/9] refactor(compiler): implement new two-way listener shape in pipeline Implements the new shape of two-way listener instructions in the template pipeline. --- .../src/template/pipeline/ir/src/enums.ts | 10 +++ .../template/pipeline/ir/src/expression.ts | 55 ++++++++++++---- .../template/pipeline/ir/src/ops/create.ts | 65 +++++++++++++++++-- .../src/template/pipeline/src/compilation.ts | 2 +- .../src/template/pipeline/src/emit.ts | 2 + .../src/template/pipeline/src/ingest.ts | 53 ++++++++++++--- .../src/template/pipeline/src/instruction.ts | 9 +++ .../src/phases/attribute_extraction.ts | 11 ++++ .../pipeline/src/phases/generate_variables.ts | 1 + .../template/pipeline/src/phases/naming.ts | 10 +++ .../src/phases/next_context_merging.ts | 2 +- .../template/pipeline/src/phases/ordering.ts | 6 +- .../src/template/pipeline/src/phases/reify.ts | 9 +++ .../pipeline/src/phases/resolve_contexts.ts | 1 + .../src/phases/resolve_dollar_event.ts | 7 +- .../pipeline/src/phases/resolve_names.ts | 3 +- .../pipeline/src/phases/save_restore_view.ts | 5 +- .../src/phases/temporary_variables.ts | 2 +- .../phases/transform_two_way_binding_set.ts | 37 +++++++++++ .../src/phases/variable_optimization.ts | 4 +- 20 files changed, 257 insertions(+), 37 deletions(-) create mode 100644 packages/compiler/src/template/pipeline/src/phases/transform_two_way_binding_set.ts diff --git a/packages/compiler/src/template/pipeline/ir/src/enums.ts b/packages/compiler/src/template/pipeline/ir/src/enums.ts index 48f8ba27dda1..c3016425295b 100644 --- a/packages/compiler/src/template/pipeline/ir/src/enums.ts +++ b/packages/compiler/src/template/pipeline/ir/src/enums.ts @@ -200,6 +200,11 @@ export enum OpKind { */ TwoWayProperty, + /** + * An operation declaring the event side of a two-way binding. + */ + TwoWayListener, + /** * The start of an i18n block. */ @@ -385,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 { diff --git a/packages/compiler/src/template/pipeline/ir/src/expression.ts b/packages/compiler/src/template/pipeline/ir/src/expression.ts index 4c7347bc69b5..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; } @@ -917,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/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 a3c887017607..634cac7e3f1a 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -922,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 @@ -983,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) { @@ -1116,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 a20f29c08d00..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, 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 7ba53156e6e3..987e53cc2d98 100644 --- a/packages/compiler/src/template/pipeline/src/phases/attribute_extraction.ts +++ b/packages/compiler/src/template/pipeline/src/phases/attribute_extraction.ts @@ -92,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/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..126653c59d07 100644 --- a/packages/compiler/src/template/pipeline/src/phases/ordering.ts +++ b/packages/compiler/src/template/pipeline/src/phases/ordering.ts @@ -34,6 +34,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: op => op.kind === ir.OpKind.TwoWayListener}, ]; /** @@ -68,8 +69,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.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 2519914be32d..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..d98264a2934c --- /dev/null +++ b/packages/compiler/src/template/pipeline/src/phases/transform_two_way_binding_set.ts @@ -0,0 +1,37 @@ +/** + * @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) { + if ((!(expr.target instanceof o.ReadPropExpr) && + !(expr.target instanceof o.ReadKeyExpr))) { + throw new Error('AssertionError: unresolved TwoWayBindingSet expression'); + } + return ng.twoWayBindingSet(expr.target, expr.value).or(expr.target.set(expr.value)); + } + return expr; + }, ir.VisitorContextFlag.InChildOperation); + } + } + } +} 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); } } From c0bfd5436126bc33687c3b9d22fe353b9138ec91 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 30 Jan 2024 20:32:44 +0100 Subject: [PATCH 7/9] refactor(compiler): allow some invalid expressions in two-way bindings that previously worked by accident In one of the earlier commits, the logic that appends `=$event` before parsing two-way bindings was removed and some validation was added to prevent unassignable expressions from being used. This ended up being problematic, because previously the parser was incorrectly allowing some invalid expressions which users came to depend on. For example, it transformed `[(value)]="a && a.b"` to `a && (a.b = $event)`. These changes add some special cases for the common breakages that came up during the TGP. --- .../compiler-cli/test/ngtsc/ngtsc_spec.ts | 163 ++++++++++++++++++ .../src/compiler_util/expression_converter.ts | 68 +++++++- .../phases/transform_two_way_binding_set.ts | 63 ++++++- .../src/template_parser/binding_parser.ts | 13 +- .../render3/r3_template_transform_spec.ts | 22 ++- 5 files changed, 312 insertions(+), 17 deletions(-) diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index edbf759b544f..1075d9b23463 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -10185,6 +10185,169 @@ function allTests(os: string) { expect(jsContents).not.toMatch('forbidOrphanRendering:'); }); }); + + describe('two-way binding backwards compatibility', () => { + 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/src/compiler_util/expression_converter.ts b/packages/compiler/src/compiler_util/expression_converter.ts index ed32975673ea..65ff3e544610 100644 --- a/packages/compiler/src/compiler_util/expression_converter.ts +++ b/packages/compiler/src/compiler_util/expression_converter.ts @@ -64,18 +64,12 @@ export function convertAssignmentActionBinding( 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) || - (!(convertedAction.expr instanceof o.ReadPropExpr) && - !(convertedAction.expr instanceof o.ReadKeyExpr))) { + 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 = new o.ExternalExpr(R3.twoWayBindingSet) - .callFn([convertedAction.expr, EventHandlerVars.event]) - .or(convertedAction.expr.set(EventHandlerVars.event)) - .toStmt(); - + convertedAction = wrapAssignmentAction(convertedAction.expr).toStmt(); const actionStmts: o.Statement[] = []; flattenStatements(convertedAction, actionStmts); prependTemporaryDecls(visitor.temporaryCount, bindingId, actionStmts); @@ -90,6 +84,64 @@ export function convertAssignmentActionBinding( 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; } 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 index d98264a2934c..f94dbbe8642f 100644 --- 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 @@ -23,11 +23,7 @@ export function transformTwoWayBindingSet(job: CompilationJob): void { if (op.kind === ir.OpKind.TwoWayListener) { ir.transformExpressionsInOp(op, (expr) => { if (expr instanceof ir.TwoWayBindingSetExpr) { - if ((!(expr.target instanceof o.ReadPropExpr) && - !(expr.target instanceof o.ReadKeyExpr))) { - throw new Error('AssertionError: unresolved TwoWayBindingSet expression'); - } - return ng.twoWayBindingSet(expr.target, expr.value).or(expr.target.set(expr.value)); + return wrapAction(expr.target, expr.value); } return expr; }, ir.VisitorContextFlag.InChildOperation); @@ -35,3 +31,60 @@ export function transformTwoWayBindingSet(job: CompilationJob): void { } } } + +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_parser/binding_parser.ts b/packages/compiler/src/template_parser/binding_parser.ts index aaf52502398a..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, AST, ASTWithSource, BindingPipe, BindingType, BoundElementProperty, EmptyExpr, KeyedRead, NonNullAssert, ParsedEvent, ParsedEventType, ParsedProperty, ParsedPropertyType, ParsedVariable, ParserError, PropertyRead, 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'; @@ -544,7 +544,16 @@ export class BindingParser { return this._isAllowedAssignmentEvent(ast.expression); } - return ast instanceof PropertyRead || ast instanceof KeyedRead; + 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; } } diff --git a/packages/compiler/test/render3/r3_template_transform_spec.ts b/packages/compiler/test/render3/r3_template_transform_spec.ts index e90e2198e13a..702187e1b64d 100644 --- a/packages/compiler/test/render3/r3_template_transform_spec.ts +++ b/packages/compiler/test/render3/r3_template_transform_spec.ts @@ -549,13 +549,11 @@ describe('R3 template transform', () => { `foo.bar?.['baz']`, 'true', '123', - 'a || b', 'a.b()', 'v()', '[1, 2, 3]', '{a: 1, b: 2, c: 3}', 'v === 1', - 'a ?? b', ]; for (const expression of unsupportedExpressions) { @@ -565,6 +563,26 @@ describe('R3 template transform', () => { } }); + 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. From e8814209dd93fd8d0a7483e67a9853570cfbe2c3 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 31 Jan 2024 10:02:41 +0100 Subject: [PATCH 8/9] refactor(compiler): maintain order between two-way and one-way properties One of the earlier commits separated one-way and two-way bindings which ended up breaking some internal targets, because it changed the assignment order. These changes bring back the old order. --- .../compiler/src/render3/view/template.ts | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index e7943a20086f..005a215095d6 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -809,8 +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 twoWayBindings: Omit[] = []; + const propertyBindings: Instruction[] = []; const attributeBindings: Omit[] = []; // Generate element input bindings @@ -832,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)) @@ -872,7 +872,9 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver } this.allocateBindingSlots(value); - if (inputType === BindingType.Property) { + // 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 this.interpolatedUpdateInstruction( @@ -883,17 +885,11 @@ 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) }); } - } else if (inputType === BindingType.TwoWay) { - // Property side of a two-way binding expression (e.g. `[(prop)]="value"`). - twoWayBindings.push({ - span: input.sourceSpan, - paramsOrFn: getBindingFunctionParams( - () => this.convertPropertyBinding(value), attrName, params) - }); } else if (inputType === BindingType.Attribute) { if (value instanceof Interpolation && getInterpolationArgsLength(value) > 1) { // attr.name="text{{value}}" and friends @@ -925,12 +921,8 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver for (const propertyBinding of propertyBindings) { this.updateInstructionWithAdvance( - elementIndex, propertyBinding.span, R3.property, propertyBinding.paramsOrFn); - } - - for (const twoWayBinding of twoWayBindings) { - this.updateInstructionWithAdvance( - elementIndex, twoWayBinding.span, R3.twoWayProperty, twoWayBinding.paramsOrFn); + elementIndex, propertyBinding.span, propertyBinding.reference, + propertyBinding.paramsOrFn); } for (const attributeBinding of attributeBindings) { From 4bbbbc1188796c6c1ceaefbf9f69ccb6be16732a Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 1 Feb 2024 05:49:30 +0100 Subject: [PATCH 9/9] refactor(compiler): update ordering in template pipeline Updates the instruction ordering logic in the template pipeline. --- .../property_bindings/GOLDEN_PARTIAL.js | 65 ++++++++++++++++++ .../property_bindings/TEST_CASES.json | 14 ++++ .../mixed_one_way_two_way_property_order.js | 6 ++ .../mixed_one_way_two_way_property_order.ts | 25 +++++++ .../GOLDEN_PARTIAL.js | 67 +++++++++++++++++++ .../r3_view_compiler_listener/TEST_CASES.json | 11 +++ .../mixed_one_way_two_way_listener_order.js | 18 +++++ .../mixed_one_way_two_way_listener_order.ts | 26 +++++++ .../template/pipeline/src/phases/ordering.ts | 19 ++++-- 9 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/mixed_one_way_two_way_property_order.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/mixed_one_way_two_way_property_order.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/mixed_one_way_two_way_listener_order.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/mixed_one_way_two_way_listener_order.ts 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/src/template/pipeline/src/phases/ordering.ts b/packages/compiler/src/template/pipeline/src/phases/ordering.ts index 126653c59d07..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,8 +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: op => op.kind === ir.OpKind.TwoWayListener}, + {test: basicListenerKindTest}, ]; /** @@ -48,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)}, ]; @@ -70,8 +79,8 @@ const UPDATE_HOST_ORDERING: Array> = [ */ const handledOpKinds = new Set([ ir.OpKind.Listener, ir.OpKind.TwoWayListener, ir.OpKind.StyleMap, ir.OpKind.ClassMap, - ir.OpKind.StyleProp, ir.OpKind.ClassProp, ir.OpKind.Property, ir.OpKind.HostProperty, - ir.OpKind.Attribute + ir.OpKind.StyleProp, ir.OpKind.ClassProp, ir.OpKind.Property, ir.OpKind.TwoWayProperty, + ir.OpKind.HostProperty, ir.OpKind.Attribute ]); /**