fix(compiler): evaluate safe navigation expressions in correct binding order (#37911)
When using the safe navigation operator in a binding expression, a temporary variable may be used for storing the result of a side-effectful call. For example, the following template uses a pipe and a safe property access: ```html <app-person-view [enabled]="enabled" [firstName]="(person$ | async)?.name"></app-person-view> ``` The result of the pipe evaluation is stored in a temporary to be able to check whether it is present. The temporary variable needs to be declared in a separate statement and this would also cause the full expression itself to be pulled out into a separate statement. This would compile into the following pseudo-code instructions: ```js var temp = null; var firstName = (temp = pipe('async', ctx.person$)) == null ? null : temp.name; property('enabled', ctx.enabled)('firstName', firstName); ``` Notice that the pipe evaluation happens before evaluating the `enabled` binding, such that the runtime's internal binding index would correspond with `enabled`, not `firstName`. This introduces a problem when the pipe uses `WrappedValue` to force a change to be detected, as the runtime would then mark the binding slot corresponding with `enabled` as dirty, instead of `firstName`. This results in the `enabled` binding to be updated, triggering setters and affecting how `OnChanges` is called. In the pseudo-code above, the intermediate `firstName` variable is not strictly necessary---it only improved readability a bit---and emitting it inline with the binding itself avoids the out-of-order execution of the pipe: ```js var temp = null; property('enabled', ctx.enabled) ('firstName', (temp = pipe('async', ctx.person$)) == null ? null : temp.name); ``` This commit introduces a new `BindingForm` that results in the above code to be generated and adds compiler and acceptance tests to verify the proper behavior. Fixes #37194 PR Close #37911
This commit is contained in:
parent
2e9fdbde9e
commit
9514fd9080
|
@ -180,6 +180,44 @@ describe('compiler compliance: bindings', () => {
|
||||||
expectEmit(result.source, template, 'Incorrect template');
|
expectEmit(result.source, template, 'Incorrect template');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should emit temporary evaluation within the binding expression for in-order execution',
|
||||||
|
() => {
|
||||||
|
// https://github.com/angular/angular/issues/37194
|
||||||
|
// Verifies that temporary expressions used for expressions with potential side-effects in
|
||||||
|
// the LHS of a safe navigation access are emitted within the binding expression itself, to
|
||||||
|
// ensure that these temporaries are evaluated during the evaluation of the binding. This
|
||||||
|
// is important for when the LHS contains a pipe, as pipe evaluation depends on the current
|
||||||
|
// binding index.
|
||||||
|
const files = {
|
||||||
|
app: {
|
||||||
|
'example.ts': `
|
||||||
|
import {Component} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: '<button [title]="myTitle" [id]="(auth()?.identity() | async)?.id" [tabindex]="1"></button>'
|
||||||
|
})
|
||||||
|
export class MyComponent {
|
||||||
|
myTitle = 'hello';
|
||||||
|
auth?: () => { identity(): any; };
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = compile(files, angularFiles);
|
||||||
|
const template = `
|
||||||
|
…
|
||||||
|
template: function MyComponent_Template(rf, ctx) {
|
||||||
|
…
|
||||||
|
if (rf & 2) {
|
||||||
|
var $tmp0$ = null;
|
||||||
|
$r3$.ɵɵproperty("title", ctx.myTitle)("id", ($tmp0$ = $r3$.ɵɵpipeBind1(1, 3, ($tmp0$ = ctx.auth()) == null ? null : $tmp0$.identity())) == null ? null : $tmp0$.id)("tabindex", 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
expectEmit(result.source, template, 'Incorrect template');
|
||||||
|
});
|
||||||
|
|
||||||
it('should chain multiple property bindings into a single instruction', () => {
|
it('should chain multiple property bindings into a single instruction', () => {
|
||||||
const files = {
|
const files = {
|
||||||
app: {
|
app: {
|
||||||
|
@ -685,6 +723,46 @@ describe('compiler compliance: bindings', () => {
|
||||||
expectEmit(source, HostBindingDirDeclaration, 'Invalid host binding code');
|
expectEmit(source, HostBindingDirDeclaration, 'Invalid host binding code');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should support host bindings with temporary expressions', () => {
|
||||||
|
const files = {
|
||||||
|
app: {
|
||||||
|
'spec.ts': `
|
||||||
|
import {Directive, NgModule} from '@angular/core';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[hostBindingDir]',
|
||||||
|
host: {'[id]': 'getData()?.id'}
|
||||||
|
})
|
||||||
|
export class HostBindingDir {
|
||||||
|
getData?: () => { id: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
@NgModule({declarations: [HostBindingDir]})
|
||||||
|
export class MyModule {}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const HostBindingDirDeclaration = `
|
||||||
|
HostBindingDir.ɵdir = $r3$.ɵɵdefineDirective({
|
||||||
|
type: HostBindingDir,
|
||||||
|
selectors: [["", "hostBindingDir", ""]],
|
||||||
|
hostVars: 1,
|
||||||
|
hostBindings: function HostBindingDir_HostBindings(rf, ctx) {
|
||||||
|
if (rf & 2) {
|
||||||
|
var $tmp0$ = null;
|
||||||
|
$r3$.ɵɵhostProperty("id", ($tmp0$ = ctx.getData()) == null ? null : $tmp0$.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = compile(files, angularFiles);
|
||||||
|
const source = result.source;
|
||||||
|
|
||||||
|
expectEmit(source, HostBindingDirDeclaration, 'Invalid host binding code');
|
||||||
|
});
|
||||||
|
|
||||||
it('should support host bindings with pure functions', () => {
|
it('should support host bindings with pure functions', () => {
|
||||||
const files = {
|
const files = {
|
||||||
app: {
|
app: {
|
||||||
|
|
|
@ -805,8 +805,7 @@ describe('i18n support in the template compiler', () => {
|
||||||
}
|
}
|
||||||
if (rf & 2) {
|
if (rf & 2) {
|
||||||
var $tmp_0_0$ = null;
|
var $tmp_0_0$ = null;
|
||||||
const $currVal_0$ = ($tmp_0_0$ = ctx.valueA.getRawValue()) == null ? null : $tmp_0_0$.getTitle();
|
$r3$.ɵɵi18nExp(($tmp_0_0$ = ctx.valueA.getRawValue()) == null ? null : $tmp_0_0$.getTitle());
|
||||||
$r3$.ɵɵi18nExp($currVal_0$);
|
|
||||||
$r3$.ɵɵi18nApply(1);
|
$r3$.ɵɵi18nApply(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1320,9 +1319,8 @@ describe('i18n support in the template compiler', () => {
|
||||||
}
|
}
|
||||||
if (rf & 2) {
|
if (rf & 2) {
|
||||||
var $tmp_2_0$ = null;
|
var $tmp_2_0$ = null;
|
||||||
const $currVal_2$ = ($tmp_2_0$ = ctx.valueA.getRawValue()) == null ? null : $tmp_2_0$.getTitle();
|
|
||||||
$r3$.ɵɵadvance(2);
|
$r3$.ɵɵadvance(2);
|
||||||
$r3$.ɵɵi18nExp($r3$.ɵɵpipeBind1(2, 3, ctx.valueA))(ctx.valueA == null ? null : ctx.valueA.a == null ? null : ctx.valueA.a.b)($currVal_2$);
|
$r3$.ɵɵi18nExp($r3$.ɵɵpipeBind1(2, 3, ctx.valueA))(ctx.valueA == null ? null : ctx.valueA.a == null ? null : ctx.valueA.a.b)(($tmp_2_0$ = ctx.valueA.getRawValue()) == null ? null : $tmp_2_0$.getTitle());
|
||||||
$r3$.ɵɵi18nApply(1);
|
$r3$.ɵɵi18nApply(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -154,6 +154,11 @@ export enum BindingForm {
|
||||||
// Try to generate a simple binding (no temporaries or statements)
|
// Try to generate a simple binding (no temporaries or statements)
|
||||||
// otherwise generate a general binding
|
// otherwise generate a general binding
|
||||||
TrySimple,
|
TrySimple,
|
||||||
|
|
||||||
|
// Inlines assignment of temporaries into the generated expression. The result may still
|
||||||
|
// have statements attached for declarations of temporary variables.
|
||||||
|
// This is the only relevant form for Ivy, the other forms are only used in ViewEngine.
|
||||||
|
Expression,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -168,7 +173,6 @@ export function convertPropertyBinding(
|
||||||
if (!localResolver) {
|
if (!localResolver) {
|
||||||
localResolver = new DefaultLocalResolver();
|
localResolver = new DefaultLocalResolver();
|
||||||
}
|
}
|
||||||
const currValExpr = createCurrValueExpr(bindingId);
|
|
||||||
const visitor =
|
const visitor =
|
||||||
new _AstToIrVisitor(localResolver, implicitReceiver, bindingId, interpolationFunction);
|
new _AstToIrVisitor(localResolver, implicitReceiver, bindingId, interpolationFunction);
|
||||||
const outputExpr: o.Expression = expressionWithoutBuiltins.visit(visitor, _Mode.Expression);
|
const outputExpr: o.Expression = expressionWithoutBuiltins.visit(visitor, _Mode.Expression);
|
||||||
|
@ -180,8 +184,11 @@ export function convertPropertyBinding(
|
||||||
|
|
||||||
if (visitor.temporaryCount === 0 && form == BindingForm.TrySimple) {
|
if (visitor.temporaryCount === 0 && form == BindingForm.TrySimple) {
|
||||||
return new ConvertPropertyBindingResult([], outputExpr);
|
return new ConvertPropertyBindingResult([], outputExpr);
|
||||||
|
} else if (form === BindingForm.Expression) {
|
||||||
|
return new ConvertPropertyBindingResult(stmts, outputExpr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currValExpr = createCurrValueExpr(bindingId);
|
||||||
stmts.push(currValExpr.set(outputExpr).toDeclStmt(o.DYNAMIC_TYPE, [o.StmtModifier.Final]));
|
stmts.push(currValExpr.set(outputExpr).toDeclStmt(o.DYNAMIC_TYPE, [o.StmtModifier.Final]));
|
||||||
return new ConvertPropertyBindingResult(stmts, currValExpr);
|
return new ConvertPropertyBindingResult(stmts, currValExpr);
|
||||||
}
|
}
|
||||||
|
|
|
@ -714,7 +714,7 @@ function createHostBindingsFunction(
|
||||||
|
|
||||||
function bindingFn(implicit: any, value: AST) {
|
function bindingFn(implicit: any, value: AST) {
|
||||||
return convertPropertyBinding(
|
return convertPropertyBinding(
|
||||||
null, implicit, value, 'b', BindingForm.TrySimple, () => error('Unexpected interpolation'));
|
null, implicit, value, 'b', BindingForm.Expression, () => error('Unexpected interpolation'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertStylingCall(
|
function convertStylingCall(
|
||||||
|
|
|
@ -1213,7 +1213,7 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
|
||||||
|
|
||||||
private convertPropertyBinding(value: AST): o.Expression {
|
private convertPropertyBinding(value: AST): o.Expression {
|
||||||
const convertedPropertyBinding = convertPropertyBinding(
|
const convertedPropertyBinding = convertPropertyBinding(
|
||||||
this, this.getImplicitReceiverExpr(), value, this.bindingContext(), BindingForm.TrySimple,
|
this, this.getImplicitReceiverExpr(), value, this.bindingContext(), BindingForm.Expression,
|
||||||
() => error('Unexpected interpolation'));
|
() => error('Unexpected interpolation'));
|
||||||
const valExpr = convertedPropertyBinding.currValExpr;
|
const valExpr = convertedPropertyBinding.currValExpr;
|
||||||
this._tempVariables.push(...convertedPropertyBinding.stmts);
|
this._tempVariables.push(...convertedPropertyBinding.stmts);
|
||||||
|
|
|
@ -6,9 +6,10 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Directive, Inject, Injectable, InjectionToken, Input, NgModule, OnDestroy, Pipe, PipeTransform, ViewChild} from '@angular/core';
|
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Directive, Inject, Injectable, InjectionToken, Input, NgModule, OnChanges, OnDestroy, Pipe, PipeTransform, SimpleChanges, ViewChild, WrappedValue} from '@angular/core';
|
||||||
import {TestBed} from '@angular/core/testing';
|
import {TestBed} from '@angular/core/testing';
|
||||||
import {expect} from '@angular/platform-browser/testing/src/matchers';
|
import {expect} from '@angular/platform-browser/testing/src/matchers';
|
||||||
|
import {ivyEnabled} from '@angular/private/testing';
|
||||||
|
|
||||||
describe('pipe', () => {
|
describe('pipe', () => {
|
||||||
@Pipe({name: 'countingPipe'})
|
@Pipe({name: 'countingPipe'})
|
||||||
|
@ -285,6 +286,133 @@ describe('pipe', () => {
|
||||||
expect(fixture.nativeElement).toHaveText('a');
|
expect(fixture.nativeElement).toHaveText('a');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('pipes within an optional chain', () => {
|
||||||
|
it('should not dirty unrelated inputs', () => {
|
||||||
|
// https://github.com/angular/angular/issues/37194
|
||||||
|
// https://github.com/angular/angular/issues/37591
|
||||||
|
// Using a pipe in the LHS of safe navigation operators would clobber unrelated bindings
|
||||||
|
// iff the pipe returns WrappedValue, incorrectly causing the unrelated binding
|
||||||
|
// to be considered changed.
|
||||||
|
const log: string[] = [];
|
||||||
|
|
||||||
|
@Component({template: `<my-cmp [value1]="1" [value2]="(value2 | pipe)?.id"></my-cmp>`})
|
||||||
|
class App {
|
||||||
|
value2 = {id: 2};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({selector: 'my-cmp', template: ''})
|
||||||
|
class MyCmp {
|
||||||
|
@Input()
|
||||||
|
set value1(value1: number) {
|
||||||
|
log.push(`set value1=${value1}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
set value2(value2: number) {
|
||||||
|
log.push(`set value2=${value2}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Pipe({name: 'pipe'})
|
||||||
|
class MyPipe implements PipeTransform {
|
||||||
|
transform(value: any): any {
|
||||||
|
log.push('pipe');
|
||||||
|
return WrappedValue.wrap(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({declarations: [App, MyCmp, MyPipe]});
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
fixture.detectChanges(/* checkNoChanges */ false);
|
||||||
|
|
||||||
|
// Both bindings should have been set. Note: ViewEngine evaluates the pipe out-of-order,
|
||||||
|
// before setting inputs.
|
||||||
|
expect(log).toEqual(
|
||||||
|
ivyEnabled ?
|
||||||
|
[
|
||||||
|
'set value1=1',
|
||||||
|
'pipe',
|
||||||
|
'set value2=2',
|
||||||
|
] :
|
||||||
|
[
|
||||||
|
'pipe',
|
||||||
|
'set value1=1',
|
||||||
|
'set value2=2',
|
||||||
|
]);
|
||||||
|
log.length = 0;
|
||||||
|
|
||||||
|
fixture.componentInstance.value2 = {id: 3};
|
||||||
|
fixture.detectChanges(/* checkNoChanges */ false);
|
||||||
|
|
||||||
|
// value1 did not change, so it should not have been set.
|
||||||
|
expect(log).toEqual([
|
||||||
|
'pipe',
|
||||||
|
'set value2=3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include unrelated inputs in ngOnChanges', () => {
|
||||||
|
// https://github.com/angular/angular/issues/37194
|
||||||
|
// https://github.com/angular/angular/issues/37591
|
||||||
|
// Using a pipe in the LHS of safe navigation operators would clobber unrelated bindings
|
||||||
|
// iff the pipe returns WrappedValue, incorrectly causing the unrelated binding
|
||||||
|
// to be considered changed.
|
||||||
|
const log: string[] = [];
|
||||||
|
|
||||||
|
@Component({template: `<my-cmp [value1]="1" [value2]="(value2 | pipe)?.id"></my-cmp>`})
|
||||||
|
class App {
|
||||||
|
value2 = {id: 2};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({selector: 'my-cmp', template: ''})
|
||||||
|
class MyCmp implements OnChanges {
|
||||||
|
@Input() value1!: number;
|
||||||
|
|
||||||
|
@Input() value2!: number;
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes.value1) {
|
||||||
|
const {previousValue, currentValue, firstChange} = changes.value1;
|
||||||
|
log.push(`change value1: ${previousValue} -> ${currentValue} (${firstChange})`);
|
||||||
|
}
|
||||||
|
if (changes.value2) {
|
||||||
|
const {previousValue, currentValue, firstChange} = changes.value2;
|
||||||
|
log.push(`change value2: ${previousValue} -> ${currentValue} (${firstChange})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Pipe({name: 'pipe'})
|
||||||
|
class MyPipe implements PipeTransform {
|
||||||
|
transform(value: any): any {
|
||||||
|
log.push('pipe');
|
||||||
|
return WrappedValue.wrap(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({declarations: [App, MyCmp, MyPipe]});
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
fixture.detectChanges(/* checkNoChanges */ false);
|
||||||
|
|
||||||
|
// Both bindings should have been included in ngOnChanges.
|
||||||
|
expect(log).toEqual([
|
||||||
|
'pipe',
|
||||||
|
'change value1: undefined -> 1 (true)',
|
||||||
|
'change value2: undefined -> 2 (true)',
|
||||||
|
]);
|
||||||
|
log.length = 0;
|
||||||
|
|
||||||
|
fixture.componentInstance.value2 = {id: 3};
|
||||||
|
fixture.detectChanges(/* checkNoChanges */ false);
|
||||||
|
|
||||||
|
// value1 did not change, so it should not have been included in ngOnChanges
|
||||||
|
expect(log).toEqual([
|
||||||
|
'pipe',
|
||||||
|
'change value2: 2 -> 3 (false)',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('pure', () => {
|
describe('pure', () => {
|
||||||
it('should call pure pipes only if the arguments change', () => {
|
it('should call pure pipes only if the arguments change', () => {
|
||||||
@Component({
|
@Component({
|
||||||
|
|
Loading…
Reference in New Issue