Skip to content

Commit 3857df1

Browse files
committed
fix(shared/helpers): don't cache errors in memoize
1 parent cd75aaf commit 3857df1

2 files changed

Lines changed: 122 additions & 6 deletions

File tree

src/shared/__tests__/helpers.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getNextOccurrence,
88
toWalletAddressUrl,
99
setDifference,
10+
memoize,
1011
} from '../helpers';
1112

1213
describe('objectEquals', () => {
@@ -165,3 +166,111 @@ describe('toWalletAddressUrl', () => {
165166
);
166167
});
167168
});
169+
170+
describe('memoize', () => {
171+
jest.useFakeTimers();
172+
173+
type SuccessResponse = { data: string };
174+
type MockFunction = () => Promise<SuccessResponse>;
175+
176+
const successResponse1: SuccessResponse = { data: 'success1' };
177+
const successResponse2: SuccessResponse = { data: 'success2' };
178+
const errorResponse = new Error('failure');
179+
180+
let mockFn: jest.MockedFunction<MockFunction>;
181+
beforeEach(() => {
182+
mockFn = jest.fn();
183+
});
184+
185+
it('should cache the result of a successful promise with max-age mechanism', async () => {
186+
mockFn.mockResolvedValueOnce(successResponse1);
187+
mockFn.mockResolvedValueOnce(successResponse2);
188+
const memoizedFn = memoize(mockFn, { maxAge: 1000, mechanism: 'max-age' });
189+
190+
const result1 = await memoizedFn();
191+
const result2 = await memoizedFn();
192+
193+
expect(mockFn).toHaveBeenCalledTimes(1);
194+
expect(result1).toBe(successResponse1);
195+
expect(result2).toBe(successResponse1);
196+
197+
jest.advanceTimersByTime(1001);
198+
const result3 = await memoizedFn();
199+
expect(mockFn).toHaveBeenCalledTimes(2);
200+
expect(result3).toBe(successResponse2);
201+
});
202+
203+
it('should cache the result of a successful promise with stale-while-revalidate mechanism', async () => {
204+
mockFn.mockResolvedValueOnce(successResponse1);
205+
mockFn.mockResolvedValueOnce(successResponse2);
206+
const memoizedFn = memoize(mockFn, {
207+
maxAge: 1000,
208+
mechanism: 'stale-while-revalidate',
209+
});
210+
211+
const result1 = await memoizedFn();
212+
const result2 = await memoizedFn();
213+
214+
expect(mockFn).toHaveBeenCalledTimes(1);
215+
expect(result1).toBe(successResponse1);
216+
expect(result2).toBe(successResponse1);
217+
218+
jest.advanceTimersByTime(1001);
219+
const result3 = await memoizedFn();
220+
expect(mockFn).toHaveBeenCalledTimes(2);
221+
expect(result3).toBe(successResponse1);
222+
223+
jest.advanceTimersByTime(50);
224+
const result4 = await memoizedFn();
225+
expect(mockFn).toHaveBeenCalledTimes(2);
226+
expect(result4).toBe(successResponse2);
227+
});
228+
229+
it('should reject if there is an error in first call with max-age mechanism', async () => {
230+
mockFn.mockRejectedValueOnce(errorResponse);
231+
mockFn.mockResolvedValueOnce(successResponse1);
232+
233+
const memoizedFn = memoize(mockFn, { maxAge: 1000, mechanism: 'max-age' });
234+
235+
await expect(memoizedFn).rejects.toBe(errorResponse);
236+
expect(mockFn).toHaveBeenCalledTimes(1);
237+
238+
const result = await memoizedFn();
239+
expect(mockFn).toHaveBeenCalledTimes(2);
240+
expect(result).toBe(successResponse1);
241+
});
242+
243+
it('should not return error response from previous call when using state-while-revalidate mechanism', async () => {
244+
mockFn.mockRejectedValueOnce(errorResponse);
245+
mockFn.mockResolvedValueOnce(successResponse1);
246+
mockFn.mockRejectedValueOnce(errorResponse);
247+
mockFn.mockResolvedValueOnce(successResponse2);
248+
249+
const memoizedFn = memoize(mockFn, {
250+
maxAge: 1000,
251+
mechanism: 'stale-while-revalidate',
252+
});
253+
254+
await expect(memoizedFn).rejects.toBe(errorResponse);
255+
expect(mockFn).toHaveBeenCalledTimes(1);
256+
257+
const result1 = await memoizedFn();
258+
expect(mockFn).toHaveBeenCalledTimes(2);
259+
expect(result1).toBe(successResponse1);
260+
261+
jest.advanceTimersByTime(1001);
262+
263+
// even though 3rd call results in an error, reuse successful response from
264+
// a previous call
265+
const result2 = await memoizedFn();
266+
expect(mockFn).toHaveBeenCalledTimes(3);
267+
expect(mockFn.mock.results.at(-1)).toEqual(
268+
expect.objectContaining(errorResponse),
269+
);
270+
expect(result2).toBe(successResponse1);
271+
272+
const result3 = await memoizedFn();
273+
expect(mockFn).toHaveBeenCalledTimes(4);
274+
expect(result3).toBe(successResponse2);
275+
});
276+
});

src/shared/helpers/misc.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,25 @@ export function memoize<T extends () => Promise<unknown>>(
6060
options: { maxAge: number; mechanism: 'stale-while-revalidate' | 'max-age' },
6161
): () => ReturnType<T> {
6262
const { maxAge, mechanism = 'max-age' } = options;
63-
let result: ReturnType<T>;
63+
6464
let lastCall = 0;
65+
const result: { promise: ReturnType<T> | null } = { promise: null };
66+
6567
return () => {
66-
const lastResult = result;
67-
if (Date.now() - lastCall > maxAge) {
68+
const lastResult = result.promise;
69+
if (!result.promise || Date.now() - lastCall > maxAge) {
6870
lastCall = Date.now();
69-
result = fn() as ReturnType<T>;
71+
const promise = fn() as ReturnType<T>;
72+
promise.catch(() => {
73+
result.promise = null;
74+
});
75+
result.promise = promise;
7076
}
71-
if (mechanism === 'stale-while-revalidate') {
77+
78+
if (mechanism === 'stale-while-revalidate' && lastResult) {
7279
return lastResult;
7380
}
74-
return result;
81+
return result.promise;
7582
};
7683
}
7784

0 commit comments

Comments
 (0)