Skip to content

Commit 2d520ae

Browse files
chuckjazvikerman
authored andcommitted
fix(compiler): Generate temporary variables for guarded expressions (#10657)
Fixes: #10639
1 parent 5d59c6e commit 2d520ae

File tree

4 files changed

+176
-24
lines changed

4 files changed

+176
-24
lines changed

modules/@angular/compiler/src/view_compiler/event_binder.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ export class CompileEventListener {
5959
this._method.resetDebugInfo(this.compileElement.nodeIndex, hostEvent);
6060
var context = isPresent(directiveInstance) ? directiveInstance :
6161
this.compileElement.view.componentContext;
62-
var actionStmts = convertCdStatementToIr(this.compileElement.view, context, hostEvent.handler);
62+
var actionStmts = convertCdStatementToIr(
63+
this.compileElement.view, context, hostEvent.handler, this.compileElement.nodeIndex);
6364
var lastIndex = actionStmts.length - 1;
6465
if (lastIndex >= 0) {
6566
var lastStatement = actionStmts[lastIndex];

modules/@angular/compiler/src/view_compiler/expression_converter.ts

Lines changed: 107 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,25 +21,45 @@ export interface NameResolver {
2121
}
2222

2323
export class ExpressionWithWrappedValueInfo {
24-
constructor(public expression: o.Expression, public needsValueUnwrapper: boolean) {}
24+
constructor(
25+
public expression: o.Expression, public needsValueUnwrapper: boolean,
26+
public temporaryCount: number) {}
2527
}
2628

2729
export function convertCdExpressionToIr(
2830
nameResolver: NameResolver, implicitReceiver: o.Expression, expression: cdAst.AST,
29-
valueUnwrapper: o.ReadVarExpr): ExpressionWithWrappedValueInfo {
30-
const visitor = new _AstToIrVisitor(nameResolver, implicitReceiver, valueUnwrapper);
31+
valueUnwrapper: o.ReadVarExpr, bindingIndex: number): ExpressionWithWrappedValueInfo {
32+
const visitor = new _AstToIrVisitor(nameResolver, implicitReceiver, valueUnwrapper, bindingIndex);
3133
const irAst: o.Expression = expression.visit(visitor, _Mode.Expression);
32-
return new ExpressionWithWrappedValueInfo(irAst, visitor.needsValueUnwrapper);
34+
return new ExpressionWithWrappedValueInfo(
35+
irAst, visitor.needsValueUnwrapper, visitor.temporaryCount);
3336
}
3437

3538
export function convertCdStatementToIr(
36-
nameResolver: NameResolver, implicitReceiver: o.Expression, stmt: cdAst.AST): o.Statement[] {
37-
const visitor = new _AstToIrVisitor(nameResolver, implicitReceiver, null);
39+
nameResolver: NameResolver, implicitReceiver: o.Expression, stmt: cdAst.AST,
40+
bindingIndex: number): o.Statement[] {
41+
const visitor = new _AstToIrVisitor(nameResolver, implicitReceiver, null, bindingIndex);
3842
let statements: o.Statement[] = [];
3943
flattenStatements(stmt.visit(visitor, _Mode.Statement), statements);
44+
prependTemporaryDecls(visitor.temporaryCount, bindingIndex, statements);
4045
return statements;
4146
}
4247

48+
function temporaryName(bindingIndex: number, temporaryNumber: number): string {
49+
return `tmp_${bindingIndex}_${temporaryNumber}`;
50+
}
51+
52+
export function temporaryDeclaration(bindingIndex: number, temporaryNumber: number): o.Statement {
53+
return new o.DeclareVarStmt(temporaryName(bindingIndex, temporaryNumber), o.NULL_EXPR);
54+
}
55+
56+
function prependTemporaryDecls(
57+
temporaryCount: number, bindingIndex: number, statements: o.Statement[]) {
58+
for (let i = temporaryCount - 1; i >= 0; i--) {
59+
statements.unshift(temporaryDeclaration(bindingIndex, i));
60+
}
61+
}
62+
4363
enum _Mode {
4464
Statement,
4565
Expression
@@ -66,13 +86,15 @@ function convertToStatementIfNeeded(mode: _Mode, expr: o.Expression): o.Expressi
6686
}
6787

6888
class _AstToIrVisitor implements cdAst.AstVisitor {
69-
private _map = new Map<cdAst.AST, cdAst.AST>();
70-
89+
private _nodeMap = new Map<cdAst.AST, cdAst.AST>();
90+
private _resultMap = new Map<cdAst.AST, o.Expression>();
91+
private _currentTemporary: number = 0;
7192
public needsValueUnwrapper: boolean = false;
93+
public temporaryCount: number = 0;
7294

7395
constructor(
7496
private _nameResolver: NameResolver, private _implicitReceiver: o.Expression,
75-
private _valueUnwrapper: o.ReadVarExpr) {}
97+
private _valueUnwrapper: o.ReadVarExpr, private bindingIndex: number) {}
7698

7799
visitBinary(ast: cdAst.Binary, mode: _Mode): any {
78100
var op: o.BinaryOperator;
@@ -273,7 +295,9 @@ class _AstToIrVisitor implements cdAst.AstVisitor {
273295
}
274296

275297
private visit(ast: cdAst.AST, mode: _Mode): any {
276-
return (this._map.get(ast) || ast).visit(this, mode);
298+
const result = this._resultMap.get(ast);
299+
if (result) return result;
300+
return (this._nodeMap.get(ast) || ast).visit(this, mode);
277301
}
278302

279303
private convertSafeAccess(
@@ -317,17 +341,30 @@ class _AstToIrVisitor implements cdAst.AstVisitor {
317341
// Notice that the first guard condition is the left hand of the left most safe access node
318342
// which comes in as leftMostSafe to this routine.
319343

320-
const condition = this.visit(leftMostSafe.receiver, mode).isBlank();
344+
let guardedExpression = this.visit(leftMostSafe.receiver, mode);
345+
let temporary: o.ReadVarExpr;
346+
if (this.needsTemporary(leftMostSafe.receiver)) {
347+
// If the expression has method calls or pipes then we need to save the result into a
348+
// temporary variable to avoid calling stateful or impure code more than once.
349+
temporary = this.allocateTemporary();
350+
351+
// Preserve the result in the temporary variable
352+
guardedExpression = temporary.set(guardedExpression);
353+
354+
// Ensure all further references to the guarded expression refer to the temporary instead.
355+
this._resultMap.set(leftMostSafe.receiver, temporary);
356+
}
357+
const condition = guardedExpression.isBlank();
321358

322359
// Convert the ast to an unguarded access to the receiver's member. The map will substitute
323360
// leftMostNode with its unguarded version in the call to `this.visit()`.
324361
if (leftMostSafe instanceof cdAst.SafeMethodCall) {
325-
this._map.set(
362+
this._nodeMap.set(
326363
leftMostSafe,
327364
new cdAst.MethodCall(
328365
leftMostSafe.span, leftMostSafe.receiver, leftMostSafe.name, leftMostSafe.args));
329366
} else {
330-
this._map.set(
367+
this._nodeMap.set(
331368
leftMostSafe,
332369
new cdAst.PropertyRead(leftMostSafe.span, leftMostSafe.receiver, leftMostSafe.name));
333370
}
@@ -337,7 +374,12 @@ class _AstToIrVisitor implements cdAst.AstVisitor {
337374

338375
// Remove the mapping. This is not strictly required as the converter only traverses each node
339376
// once but is safer if the conversion is changed to traverse the nodes more than once.
340-
this._map.delete(leftMostSafe);
377+
this._nodeMap.delete(leftMostSafe);
378+
379+
// If we allcoated a temporary, release it.
380+
if (temporary) {
381+
this.releaseTemporary(temporary);
382+
}
341383

342384
// Produce the conditional
343385
return condition.conditional(o.literal(null), access);
@@ -351,8 +393,8 @@ class _AstToIrVisitor implements cdAst.AstVisitor {
351393
// then to:
352394
// a == null ? null : a.b.c == null ? null : a.b.c.d.e
353395
private leftMostSafeNode(ast: cdAst.AST): cdAst.SafePropertyRead|cdAst.SafeMethodCall {
354-
let visit = (visitor: cdAst.AstVisitor, ast: cdAst.AST): any => {
355-
return (this._map.get(ast) || ast).visit(visitor);
396+
const visit = (visitor: cdAst.AstVisitor, ast: cdAst.AST): any => {
397+
return (this._nodeMap.get(ast) || ast).visit(visitor);
356398
};
357399
return ast.visit({
358400
visitBinary(ast: cdAst.Binary) { return null; },
@@ -378,6 +420,55 @@ class _AstToIrVisitor implements cdAst.AstVisitor {
378420
}
379421
});
380422
}
423+
424+
// Returns true of the AST includes a method or a pipe indicating that, if the
425+
// expression is used as the target of a safe property or method access then
426+
// the expression should be stored into a temporary variable.
427+
private needsTemporary(ast: cdAst.AST): boolean {
428+
const visit = (visitor: cdAst.AstVisitor, ast: cdAst.AST): boolean => {
429+
return ast && (this._nodeMap.get(ast) || ast).visit(visitor);
430+
};
431+
const visitSome = (visitor: cdAst.AstVisitor, ast: cdAst.AST[]): boolean => {
432+
return ast.some(ast => visit(visitor, ast));
433+
};
434+
return ast.visit({
435+
visitBinary(ast: cdAst.Binary):
436+
boolean{return visit(this, ast.left) || visit(this, ast.right);},
437+
visitChain(ast: cdAst.Chain) { return false; },
438+
visitConditional(ast: cdAst.Conditional):
439+
boolean{return visit(this, ast.condition) || visit(this, ast.trueExp) ||
440+
visit(this, ast.falseExp);},
441+
visitFunctionCall(ast: cdAst.FunctionCall) { return true; },
442+
visitImplicitReceiver(ast: cdAst.ImplicitReceiver) { return false; },
443+
visitInterpolation(ast: cdAst.Interpolation) { return visitSome(this, ast.expressions); },
444+
visitKeyedRead(ast: cdAst.KeyedRead) { return false; },
445+
visitKeyedWrite(ast: cdAst.KeyedWrite) { return false; },
446+
visitLiteralArray(ast: cdAst.LiteralArray) { return true; },
447+
visitLiteralMap(ast: cdAst.LiteralMap) { return true; },
448+
visitLiteralPrimitive(ast: cdAst.LiteralPrimitive) { return false; },
449+
visitMethodCall(ast: cdAst.MethodCall) { return true; },
450+
visitPipe(ast: cdAst.BindingPipe) { return true; },
451+
visitPrefixNot(ast: cdAst.PrefixNot) { return visit(this, ast.expression); },
452+
visitPropertyRead(ast: cdAst.PropertyRead) { return false; },
453+
visitPropertyWrite(ast: cdAst.PropertyWrite) { return false; },
454+
visitQuote(ast: cdAst.Quote) { return false; },
455+
visitSafeMethodCall(ast: cdAst.SafeMethodCall) { return true; },
456+
visitSafePropertyRead(ast: cdAst.SafePropertyRead) { return false; }
457+
});
458+
}
459+
460+
private allocateTemporary(): o.ReadVarExpr {
461+
const tempNumber = this._currentTemporary++;
462+
this.temporaryCount = Math.max(this._currentTemporary, this.temporaryCount);
463+
return new o.ReadVarExpr(temporaryName(this.bindingIndex, tempNumber));
464+
}
465+
466+
private releaseTemporary(temporary: o.ReadVarExpr) {
467+
this._currentTemporary--;
468+
if (temporary.name != temporaryName(this.bindingIndex, this._currentTemporary)) {
469+
throw new BaseException(`Temporary ${temporary.name} released out of order`);
470+
}
471+
}
381472
}
382473

383474
function flattenStatements(arg: any, output: o.Statement[]) {

modules/@angular/compiler/src/view_compiler/property_binder.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {CompileElement, CompileNode} from './compile_element';
2121
import {CompileMethod} from './compile_method';
2222
import {CompileView} from './compile_view';
2323
import {DetectChangesVars, ViewProperties} from './constants';
24-
import {convertCdExpressionToIr} from './expression_converter';
24+
import {convertCdExpressionToIr, temporaryDeclaration} from './expression_converter';
2525

2626
function createBindFieldExpr(exprIndex: number): o.ReadPropExpr {
2727
return o.THIS_EXPR.prop(`_expr_${exprIndex}`);
@@ -36,14 +36,20 @@ const _animationViewCheckedFlagMap = new Map<CompileView, boolean>();
3636
function bind(
3737
view: CompileView, currValExpr: o.ReadVarExpr, fieldExpr: o.ReadPropExpr,
3838
parsedExpression: cdAst.AST, context: o.Expression, actions: o.Statement[],
39-
method: CompileMethod) {
40-
var checkExpression =
41-
convertCdExpressionToIr(view, context, parsedExpression, DetectChangesVars.valUnwrapper);
39+
method: CompileMethod, bindingIndex: number) {
40+
var checkExpression = convertCdExpressionToIr(
41+
view, context, parsedExpression, DetectChangesVars.valUnwrapper, bindingIndex);
4242
if (isBlank(checkExpression.expression)) {
4343
// e.g. an empty expression was given
4444
return;
4545
}
4646

47+
if (checkExpression.temporaryCount) {
48+
for (let i = 0; i < checkExpression.temporaryCount; i++) {
49+
method.addStmt(temporaryDeclaration(bindingIndex, i));
50+
}
51+
}
52+
4753
// private is fine here as no child view will reference the cached value...
4854
view.fields.push(new o.ClassField(fieldExpr.name, null, [o.StmtModifier.Private]));
4955
view.createMethod.addStmt(
@@ -80,7 +86,7 @@ export function bindRenderText(
8086
[o.THIS_EXPR.prop('renderer')
8187
.callMethod('setText', [compileNode.renderNode, currValExpr])
8288
.toStmt()],
83-
view.detectChangesRenderPropertiesMethod);
89+
view.detectChangesRenderPropertiesMethod, bindingIndex);
8490
}
8591

8692
function bindAndWriteToRenderer(
@@ -183,7 +189,7 @@ function bindAndWriteToRenderer(
183189

184190
bind(
185191
view, currValExpr, fieldExpr, boundProp.value, context, updateStmts,
186-
view.detectChangesRenderPropertiesMethod);
192+
view.detectChangesRenderPropertiesMethod, view.bindings.length);
187193
});
188194
}
189195

@@ -274,7 +280,7 @@ export function bindDirectiveInputs(
274280
}
275281
bind(
276282
view, currValExpr, fieldExpr, input.value, view.componentContext, statements,
277-
detectChangesInInputsMethod);
283+
detectChangesInInputsMethod, bindingIndex);
278284
});
279285
if (isOnPushComp) {
280286
detectChangesInInputsMethod.addStmt(new o.IfStmt(DetectChangesVars.changed, [

modules/@angular/core/test/linker/regression_integration_spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,39 @@ function declareTests({useJit}: {useJit: boolean}) {
7272
async.done();
7373
});
7474
}));
75+
76+
it('should only evaluate stateful pipes once - #10639',
77+
inject(
78+
[TestComponentBuilder, AsyncTestCompleter],
79+
(tcb: TestComponentBuilder, async: AsyncTestCompleter) => {
80+
tcb.overrideView(
81+
MyComp1,
82+
new ViewMetadata(
83+
{template: '{{(null|countingPipe)?.value}}', pipes: [CountingPipe]}))
84+
.createAsync(MyComp1)
85+
.then(fixture => {
86+
CountingPipe.reset();
87+
fixture.detectChanges(/* checkNoChanges */ false);
88+
expect(fixture.nativeElement).toHaveText('counting pipe value');
89+
expect(CountingPipe.calls).toBe(1);
90+
async.done();
91+
});
92+
}));
93+
94+
it('should only evaluate methods once - #10639',
95+
inject(
96+
[TestComponentBuilder, AsyncTestCompleter],
97+
(tcb: TestComponentBuilder, async: AsyncTestCompleter) => {
98+
tcb.overrideView(MyCountingComp, new ViewMetadata({template: '{{method()?.value}}'}))
99+
.createAsync(MyCountingComp)
100+
.then(fixture => {
101+
MyCountingComp.reset();
102+
fixture.detectChanges(/* checkNoChanges */ false);
103+
expect(fixture.nativeElement).toHaveText('counting method value');
104+
expect(MyCountingComp.calls).toBe(1);
105+
async.done();
106+
});
107+
}));
75108
});
76109

77110
describe('providers', () => {
@@ -204,6 +237,27 @@ class CustomPipe implements PipeTransform {
204237
class CmpWithNgContent {
205238
}
206239

240+
@Component({selector: 'counting-cmp', template: ''})
241+
class MyCountingComp {
242+
method(): {value: string}|undefined {
243+
MyCountingComp.calls++;
244+
return {value: 'counting method value'};
245+
}
246+
247+
static reset() { MyCountingComp.calls = 0; }
248+
static calls = 0;
249+
}
250+
251+
@Pipe({name: 'countingPipe'})
252+
class CountingPipe implements PipeTransform {
253+
transform(value: any): any {
254+
CountingPipe.calls++;
255+
return {value: 'counting pipe value'};
256+
}
257+
static reset() { CountingPipe.calls = 0; }
258+
static calls = 0;
259+
}
260+
207261
@Component({
208262
selector: 'left',
209263
template: `L<right *ngIf="false"></right>`,

0 commit comments

Comments
 (0)