fix(core): don't re-invoke pure pipes that throw and arguments are the same (#35827)

Pure pipes are not invoked again until their arguments are modified. The same
rule should apply to pure pipes that throw an exception. This fix ensures that
a pure pipe is not re-invoked if it throws an exception and arguments are not
changed.

PR Close #35827
This commit is contained in:
Pawel Kozlowski 2020-03-03 14:26:04 +01:00 committed by Matias Niemelä
parent 6f95bc915d
commit 19cfaf7f4c
2 changed files with 125 additions and 5 deletions

View File

@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {assertDataInRange} from '../util/assert';
import {bindingUpdated, bindingUpdated2, bindingUpdated3, bindingUpdated4, getBinding, updateBinding} from './bindings'; import {bindingUpdated, bindingUpdated2, bindingUpdated3, bindingUpdated4, getBinding, updateBinding} from './bindings';
import {LView} from './interfaces/view'; import {LView} from './interfaces/view';
import {getBindingRoot, getLView} from './state'; import {getBindingRoot, getLView} from './state';
@ -277,6 +278,18 @@ export function ɵɵpureFunctionV(
return pureFunctionVInternal(getLView(), getBindingRoot(), slotOffset, pureFn, exps, thisArg); return pureFunctionVInternal(getLView(), getBindingRoot(), slotOffset, pureFn, exps, thisArg);
} }
/**
* Results of a pure function invocation are stored in LView in a dedicated slot that is initialized
* to NO_CHANGE. In rare situations a pure pipe might throw an exception on the very first
* invocation and not produce any valid results. In this case LView would keep holding the NO_CHANGE
* value. The NO_CHANGE is not something that we can use in expressions / bindings thus we convert
* it to `undefined`.
*/
function getPureFunctionReturnValue(lView: LView, returnValueIndex: number) {
ngDevMode && assertDataInRange(lView, returnValueIndex);
const lastReturnValue = lView[returnValueIndex];
return lastReturnValue === NO_CHANGE ? undefined : lastReturnValue;
}
/** /**
* If the value of the provided exp has changed, calls the pure function to return * If the value of the provided exp has changed, calls the pure function to return
@ -296,7 +309,7 @@ export function pureFunction1Internal(
const bindingIndex = bindingRoot + slotOffset; const bindingIndex = bindingRoot + slotOffset;
return bindingUpdated(lView, bindingIndex, exp) ? return bindingUpdated(lView, bindingIndex, exp) ?
updateBinding(lView, bindingIndex + 1, thisArg ? pureFn.call(thisArg, exp) : pureFn(exp)) : updateBinding(lView, bindingIndex + 1, thisArg ? pureFn.call(thisArg, exp) : pureFn(exp)) :
getBinding(lView, bindingIndex + 1); getPureFunctionReturnValue(lView, bindingIndex + 1);
} }
@ -321,7 +334,7 @@ export function pureFunction2Internal(
updateBinding( updateBinding(
lView, bindingIndex + 2, lView, bindingIndex + 2,
thisArg ? pureFn.call(thisArg, exp1, exp2) : pureFn(exp1, exp2)) : thisArg ? pureFn.call(thisArg, exp1, exp2) : pureFn(exp1, exp2)) :
getBinding(lView, bindingIndex + 2); getPureFunctionReturnValue(lView, bindingIndex + 2);
} }
/** /**
@ -347,7 +360,7 @@ export function pureFunction3Internal(
updateBinding( updateBinding(
lView, bindingIndex + 3, lView, bindingIndex + 3,
thisArg ? pureFn.call(thisArg, exp1, exp2, exp3) : pureFn(exp1, exp2, exp3)) : thisArg ? pureFn.call(thisArg, exp1, exp2, exp3) : pureFn(exp1, exp2, exp3)) :
getBinding(lView, bindingIndex + 3); getPureFunctionReturnValue(lView, bindingIndex + 3);
} }
@ -376,7 +389,7 @@ export function pureFunction4Internal(
updateBinding( updateBinding(
lView, bindingIndex + 4, lView, bindingIndex + 4,
thisArg ? pureFn.call(thisArg, exp1, exp2, exp3, exp4) : pureFn(exp1, exp2, exp3, exp4)) : thisArg ? pureFn.call(thisArg, exp1, exp2, exp3, exp4) : pureFn(exp1, exp2, exp3, exp4)) :
getBinding(lView, bindingIndex + 4); getPureFunctionReturnValue(lView, bindingIndex + 4);
} }
/** /**
@ -403,5 +416,5 @@ export function pureFunctionVInternal(
bindingUpdated(lView, bindingIndex++, exps[i]) && (different = true); bindingUpdated(lView, bindingIndex++, exps[i]) && (different = true);
} }
return different ? updateBinding(lView, bindingIndex, pureFn.apply(thisArg, exps)) : return different ? updateBinding(lView, bindingIndex, pureFn.apply(thisArg, exps)) :
getBinding(lView, bindingIndex); getPureFunctionReturnValue(lView, bindingIndex);
} }

View File

@ -582,4 +582,111 @@ describe('pipe', () => {
}); });
describe('pure pipe error handling', () => {
it('should not re-invoke pure pipes if it fails initially', () => {
@Pipe({name: 'throwPipe', pure: true})
class ThrowPipe implements PipeTransform {
transform(): never { throw new Error('ThrowPipeError'); }
}
@Component({template: `{{val | throwPipe}}`})
class App {
val = 'anything';
}
const fixture =
TestBed.configureTestingModule({declarations: [App, ThrowPipe]}).createComponent(App);
// first invocation
expect(() => fixture.detectChanges()).toThrowError(/ThrowPipeError/);
// second invocation - should not throw
fixture.detectChanges();
});
it('should display the last known result from a pure pipe when it throws', () => {
@Pipe({name: 'throwPipe', pure: true})
class ThrowPipe implements PipeTransform {
transform(value: string): string {
if (value === 'KO') {
throw new Error('ThrowPipeError');
} else {
return value;
}
}
}
@Component({template: `{{val | throwPipe}}`})
class App {
val = 'anything';
}
const fixture =
TestBed.configureTestingModule({declarations: [App, ThrowPipe]}).createComponent(App);
// first invocation - no error thrown
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe('anything');
// second invocation when the error is thrown
fixture.componentInstance.val = 'KO';
expect(() => fixture.detectChanges()).toThrowError(/ThrowPipeError/);
expect(fixture.nativeElement.textContent).toBe('anything');
// third invocation with no changes to input - should not thrown and preserve the last known
// results
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe('anything');
});
describe('pure pipe error handling with multiple arguments', () => {
const args: string[] = new Array(10).fill(':0');
for (let numberOfPipeArgs = 0; numberOfPipeArgs < args.length; numberOfPipeArgs++) {
it(`should not invoke ${numberOfPipeArgs} argument pure pipe second time if it throws unless input changes`,
() => {
// https://stackblitz.com/edit/angular-mbx2pg
const log: string[] = [];
@Pipe({name: 'throw', pure: true})
class ThrowPipe implements PipeTransform {
transform(): never {
log.push('throw');
throw new Error('ThrowPipeError');
}
}
@Component({template: `{{val | throw${args.slice(0, numberOfPipeArgs).join('')}}}`})
class App {
val = 'anything';
}
const fixture = TestBed.configureTestingModule({declarations: [App, ThrowPipe]})
.createComponent(App);
// First invocation of detect changes should throw.
expect(() => fixture.detectChanges()).toThrowError(/ThrowPipeError/);
expect(log).toEqual(['throw']);
// Second invocation should not throw as input to the `throw` pipe has not changed and
// the pipe is pure.
log.length = 0;
expect(() => fixture.detectChanges()).not.toThrow();
expect(log).toEqual([]);
fixture.componentInstance.val = 'change';
// First invocation of detect changes should throw because the input changed.
expect(() => fixture.detectChanges()).toThrowError(/ThrowPipeError/);
expect(log).toEqual(['throw']);
// Second invocation should not throw as input to the `throw` pipe has not changed and
// the pipe is pure.
log.length = 0;
expect(() => fixture.detectChanges()).not.toThrow();
expect(log).toEqual([]);
});
}
});
});
}); });