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:
@ -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({
|
||||
|
Reference in New Issue
Block a user