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:
@ -180,6 +180,44 @@ describe('compiler compliance: bindings', () => {
|
||||
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', () => {
|
||||
const files = {
|
||||
app: {
|
||||
@ -685,6 +723,46 @@ describe('compiler compliance: bindings', () => {
|
||||
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', () => {
|
||||
const files = {
|
||||
app: {
|
||||
|
@ -805,8 +805,7 @@ describe('i18n support in the template compiler', () => {
|
||||
}
|
||||
if (rf & 2) {
|
||||
var $tmp_0_0$ = null;
|
||||
const $currVal_0$ = ($tmp_0_0$ = ctx.valueA.getRawValue()) == null ? null : $tmp_0_0$.getTitle();
|
||||
$r3$.ɵɵi18nExp($currVal_0$);
|
||||
$r3$.ɵɵi18nExp(($tmp_0_0$ = ctx.valueA.getRawValue()) == null ? null : $tmp_0_0$.getTitle());
|
||||
$r3$.ɵɵi18nApply(1);
|
||||
}
|
||||
}
|
||||
@ -1320,9 +1319,8 @@ describe('i18n support in the template compiler', () => {
|
||||
}
|
||||
if (rf & 2) {
|
||||
var $tmp_2_0$ = null;
|
||||
const $currVal_2$ = ($tmp_2_0$ = ctx.valueA.getRawValue()) == null ? null : $tmp_2_0$.getTitle();
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user