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:
parent
6f95bc915d
commit
19cfaf7f4c
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user