Skip to content

Commit 99e1313

Browse files
alex-okrushkobrandonroberts
authored andcommitted
fix(store): Compare results in addition to arguments change in memoizer (#1175)
1 parent 90e6718 commit 99e1313

File tree

3 files changed

+109
-11
lines changed

3 files changed

+109
-11
lines changed

modules/store/spec/selector.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
createFeatureSelector,
55
defaultMemoize,
66
createSelectorFactory,
7+
resultMemoize,
8+
MemoizedProjection,
79
} from '@ngrx/store';
810
import { map, distinctUntilChanged } from 'rxjs/operators';
911

@@ -285,4 +287,72 @@ describe('Selectors', () => {
285287
expect(anyFn.calls.count()).toEqual(1);
286288
});
287289
});
290+
291+
describe('resultMemoize', () => {
292+
let projectionFnSpy: jasmine.Spy;
293+
const ARRAY = ['a', 'ab', 'b'];
294+
const ARRAY_CHANGED = [...ARRAY, 'bc'];
295+
const A_FILTER: { by: string } = { by: 'a' };
296+
const B_FILTER: { by: string } = { by: 'b' };
297+
298+
let arrayMemoizer: MemoizedProjection;
299+
300+
// Compare a and b on equality. If a and b are Arrays then compare them
301+
// on their content.
302+
function isResultEqual(a: any, b: any) {
303+
if (a instanceof Array) {
304+
return a.length === b.length && a.every(fromA => b.includes(fromA));
305+
}
306+
// Default comparison
307+
return a === b;
308+
}
309+
310+
beforeEach(() => {
311+
projectionFnSpy = jasmine
312+
.createSpy('projectionFn')
313+
.and.callFake((arr: string[], filter: { by: string }) =>
314+
arr.filter(item => item.startsWith(filter.by))
315+
);
316+
317+
arrayMemoizer = resultMemoize(projectionFnSpy, isResultEqual);
318+
});
319+
320+
it('should not rerun projector function when arguments stayed the same', () => {
321+
arrayMemoizer.memoized(ARRAY, A_FILTER);
322+
arrayMemoizer.memoized(ARRAY, A_FILTER);
323+
324+
expect(projectionFnSpy.calls.count()).toBe(1);
325+
});
326+
327+
it('should rerun projector function when arguments changed', () => {
328+
arrayMemoizer.memoized(ARRAY, A_FILTER);
329+
arrayMemoizer.memoized(ARRAY_CHANGED, A_FILTER);
330+
331+
expect(projectionFnSpy.calls.count()).toBe(2);
332+
});
333+
334+
it('should return the same instance of results when projector function produces the same results array', () => {
335+
const result1 = arrayMemoizer.memoized(ARRAY, A_FILTER);
336+
const result2 = arrayMemoizer.memoized(ARRAY, A_FILTER);
337+
338+
expect(result1).toBe(result2);
339+
});
340+
341+
it('should return the same instance of results when projector function produces similar results array', () => {
342+
const result1 = arrayMemoizer.memoized(ARRAY, A_FILTER);
343+
const result2 = arrayMemoizer.memoized(ARRAY_CHANGED, A_FILTER);
344+
345+
expect(result1).toBe(result2);
346+
});
347+
348+
it('should return the new instance of results when projector function produces different result', () => {
349+
const result1 = arrayMemoizer.memoized(ARRAY, A_FILTER);
350+
const result2 = arrayMemoizer.memoized(ARRAY_CHANGED, B_FILTER);
351+
352+
expect(result1).toBeDefined();
353+
expect(result2).toBeDefined();
354+
expect(result1).not.toBe(result2);
355+
expect(result1).not.toEqual(result2);
356+
});
357+
});
288358
});

modules/store/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export {
2525
MemoizeFn,
2626
MemoizedProjection,
2727
MemoizedSelector,
28+
resultMemoize,
2829
} from './selector';
2930
export { State, StateObservable, reduceState } from './state';
3031
export {

modules/store/src/selector.ts

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export type MemoizedProjection = { memoized: AnyFn; reset: () => void };
66

77
export type MemoizeFn = (t: AnyFn) => MemoizedProjection;
88

9+
export type ComparatorFn = (a: any, b: any) => boolean;
10+
911
export interface MemoizedSelector<State, Result>
1012
extends Selector<State, Result> {
1113
release(): void;
@@ -16,36 +18,61 @@ export function isEqualCheck(a: any, b: any): boolean {
1618
return a === b;
1719
}
1820

21+
function isArgumentsChanged(
22+
args: IArguments,
23+
lastArguments: IArguments,
24+
comparator: ComparatorFn
25+
) {
26+
for (let i = 0; i < args.length; i++) {
27+
if (!comparator(args[i], lastArguments[i])) {
28+
return true;
29+
}
30+
}
31+
return false;
32+
}
33+
34+
export function resultMemoize(
35+
projectionFn: AnyFn,
36+
isResultEqual: ComparatorFn
37+
) {
38+
return defaultMemoize(projectionFn, isEqualCheck, isResultEqual);
39+
}
40+
1941
export function defaultMemoize(
20-
t: AnyFn,
21-
isEqual = isEqualCheck
42+
projectionFn: AnyFn,
43+
isArgumentsEqual = isEqualCheck,
44+
isResultEqual = isEqualCheck
2245
): MemoizedProjection {
2346
let lastArguments: null | IArguments = null;
47+
// tslint:disable-next-line:no-any anything could be the result.
2448
let lastResult: any = null;
2549

2650
function reset() {
2751
lastArguments = null;
2852
lastResult = null;
2953
}
3054

55+
// tslint:disable-next-line:no-any anything could be the result.
3156
function memoized(): any {
3257
if (!lastArguments) {
33-
lastResult = t.apply(null, arguments);
58+
lastResult = projectionFn.apply(null, arguments);
3459
lastArguments = arguments;
35-
3660
return lastResult;
3761
}
3862

39-
for (let i = 0; i < arguments.length; i++) {
40-
if (!isEqual(arguments[i], lastArguments[i])) {
41-
lastResult = t.apply(null, arguments);
42-
lastArguments = arguments;
63+
if (!isArgumentsChanged(arguments, lastArguments, isArgumentsEqual)) {
64+
return lastResult;
65+
}
4366

44-
return lastResult;
45-
}
67+
const newResult = projectionFn.apply(null, arguments);
68+
if (isResultEqual(lastResult, newResult)) {
69+
return lastResult;
4670
}
4771

48-
return lastResult;
72+
lastResult = newResult;
73+
lastArguments = arguments;
74+
75+
return newResult;
4976
}
5077

5178
return { memoized, reset };

0 commit comments

Comments
 (0)