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:
JoostK
2020-07-03 19:35:44 +02:00
committed by Andrew Kushnir
parent 2e9fdbde9e
commit 9514fd9080
6 changed files with 219 additions and 8 deletions

View File

@ -6,9 +6,10 @@
* 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 {expect} from '@angular/platform-browser/testing/src/matchers';
import {ivyEnabled} from '@angular/private/testing';
describe('pipe', () => {
@Pipe({name: 'countingPipe'})
@ -285,6 +286,133 @@ describe('pipe', () => {
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', () => {
it('should call pure pipes only if the arguments change', () => {
@Component({