Skip to content

Commit b3d56ff

Browse files
committed
Implement request caching with deduplication and abort handling
1 parent 9100990 commit b3d56ff

File tree

2 files changed

+168
-0
lines changed

2 files changed

+168
-0
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { getCachedRequestPromise } from "../requestCache";
3+
4+
describe("requestCache", () => {
5+
it("caches result per request", async () => {
6+
const mockFn = vi.fn().mockResolvedValue("result");
7+
const request = new Request("http://localhost");
8+
9+
const result1 = await getCachedRequestPromise(
10+
"mockFn",
11+
mockFn,
12+
["arg1"],
13+
request
14+
);
15+
const result2 = await getCachedRequestPromise(
16+
"mockFn",
17+
mockFn,
18+
["arg1"],
19+
request
20+
);
21+
22+
expect(result1).toBe("result");
23+
expect(result2).toBe("result");
24+
expect(mockFn).toHaveBeenCalledTimes(1);
25+
});
26+
27+
it("does not share cache between requests", async () => {
28+
const mockFn = vi.fn().mockResolvedValue("result");
29+
const request1 = new Request("http://localhost");
30+
const request2 = new Request("http://localhost");
31+
32+
await getCachedRequestPromise("mockFn", mockFn, ["arg1"], request1);
33+
await getCachedRequestPromise("mockFn", mockFn, ["arg1"], request2);
34+
35+
expect(mockFn).toHaveBeenCalledTimes(2);
36+
});
37+
38+
it("throws error for restricted function names", () => {
39+
const mockFn = async () => "result";
40+
const request = new Request("http://localhost");
41+
42+
expect(() =>
43+
getCachedRequestPromise("default", mockFn, [], request)
44+
).toThrow("Must be named functions to support caching.");
45+
});
46+
47+
it("allows caching if a valid name is provided", async () => {
48+
const mockFn = async () => "result";
49+
const request = new Request("http://localhost");
50+
51+
const result = await getCachedRequestPromise(
52+
"customLabel",
53+
mockFn,
54+
[],
55+
request
56+
);
57+
expect(result).toBe("result");
58+
});
59+
60+
it("clears request cache on abort", async () => {
61+
const mockFn = vi.fn().mockResolvedValue("result");
62+
const controller = new AbortController();
63+
const request = new Request("http://localhost", {
64+
signal: controller.signal,
65+
});
66+
67+
await getCachedRequestPromise("mockFn", mockFn, ["arg1"], request);
68+
69+
// Abort the request
70+
controller.abort();
71+
72+
await getCachedRequestPromise("mockFn", mockFn, ["arg1"], request);
73+
74+
expect(mockFn).toHaveBeenCalledTimes(2);
75+
});
76+
77+
it("should behave consistently if request is aborted BEFORE first use", async () => {
78+
const mockFn = vi.fn().mockResolvedValue("result");
79+
const controller = new AbortController();
80+
const request = new Request("http://localhost", {
81+
signal: controller.signal,
82+
});
83+
84+
// Abort before first use
85+
controller.abort();
86+
87+
// First use: populates cache (unavoidable currently as we push after check)
88+
await getCachedRequestPromise("mockFn", mockFn, ["arg1"], request);
89+
expect(mockFn).toHaveBeenCalledTimes(1);
90+
91+
// Second use: should be cleared/ignored and re-executed?
92+
// Currently it returns cached value because listener wasn't attached.
93+
await getCachedRequestPromise("mockFn", mockFn, ["arg1"], request);
94+
95+
// If consistent with above, it should be 2.
96+
// If inconsistent (bug), it is 1.
97+
expect(mockFn).toHaveBeenCalledTimes(2);
98+
});
99+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import isEqual from "lodash/isEqual";
2+
3+
const restrictedNames = new Set(["", "anonymous", "default"]);
4+
5+
type CacheEntry = {
6+
funcName: string;
7+
inputs: unknown[];
8+
promise: Promise<unknown>;
9+
};
10+
11+
const requestScopedCaches = new WeakMap<Request, CacheEntry[]>();
12+
function getCache(request: Request) {
13+
let cache = requestScopedCaches.get(request);
14+
if (!cache) {
15+
cache = [];
16+
requestScopedCaches.set(request, cache);
17+
18+
const signal = request.signal;
19+
if (signal) {
20+
if (signal.aborted) {
21+
cache.splice(0, cache.length);
22+
} else {
23+
signal.addEventListener(
24+
"abort",
25+
() => {
26+
cache?.splice(0, cache.length);
27+
},
28+
{ once: true }
29+
);
30+
}
31+
}
32+
}
33+
34+
if (request.signal?.aborted) {
35+
cache.splice(0, cache.length);
36+
}
37+
38+
return cache;
39+
}
40+
41+
export function getCachedRequestPromise<Args extends unknown[], Result>(
42+
funcName: string,
43+
func: (...inputs: Args) => Promise<Result>,
44+
inputs: Args,
45+
request: Request
46+
): Promise<Result> {
47+
if (restrictedNames.has(funcName)) {
48+
throw new Error("Must be named functions to support caching.");
49+
}
50+
51+
const cache = getCache(request);
52+
for (const cacheEntry of cache) {
53+
if (
54+
cacheEntry.funcName === funcName &&
55+
isEqual(cacheEntry.inputs, inputs)
56+
) {
57+
return cacheEntry.promise as Promise<Result>;
58+
}
59+
}
60+
61+
const promise = func(...inputs) as Promise<Result>;
62+
cache.push({
63+
funcName: funcName,
64+
inputs,
65+
promise,
66+
});
67+
68+
return promise;
69+
}

0 commit comments

Comments
 (0)