Skip to content

Commit ffb0b04

Browse files
committed
Refactor request deduplication and address PR #1636 review comments
- Rename `getCachedRequestPromise` to `deduplicatePromiseForRequest` to better reflect its purpose. - Rename `getRequestScopedDapper` to `getDapperForRequest`. - Improve cache management: - Immediately handle aborted requests to prevent race conditions. - Remove rejected promises from cache to allow retries. - Use `WeakMap.delete` for safer cache clearing instead of array splicing. - Add docstrings explaining usage, performance implications, and limitations (e.g., only caching "get" methods). - Expand test coverage: - Verify promise reference equality. - Ensure different arguments create distinct cache entries. - Test behavior with aborted signals. - Remove- Rename `getCachedRequestPromise` to `deduplicatePromiseForRequest` to better reflect its purpose. - Rename `getRequestScopedDapper` to `getDapperForRequest`. - Improve cache management: - Immediately handle aborted requests to prevent race conditions. - Remove rejected promises from cache to allow retries. - Use `WeakMap.delete` for safer cache clearing instead of array splicing. - Add docstrings explaining usage, performance implications, and limitations (e.g., only caching "get" methods). - Expand test coverage: - Verify promise reference equality. - Ensure different arguments create distinct cache entries. - Test behavior with aborted signals. - Remove resolved TODO in entry.client.tsx
1 parent 1a1454b commit ffb0b04

File tree

5 files changed

+141
-47
lines changed

5 files changed

+141
-47
lines changed

apps/cyberstorm-remix/app/entry.client.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ const publicEnvVariables = getPublicEnvVariables([
2020
"VITE_COOKIE_DOMAIN",
2121
]);
2222

23-
// TODO: Check if this is synchronous or not
2423
Sentry.init({
2524
dsn: publicEnvVariables.VITE_CLIENT_SENTRY_DSN,
2625
integrations: [

apps/cyberstorm-remix/cyberstorm/utils/__tests__/dapperSingleton.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
22
import {
33
initializeClientDapper,
44
getClientDapper,
5-
getRequestScopedDapper,
5+
getDapperForRequest,
66
resetDapperSingletonForTest,
77
} from "../dapperSingleton";
88
import { DapperTs } from "@thunderstore/dapper-ts";
@@ -69,17 +69,17 @@ describe("dapperSingleton", () => {
6969
});
7070
});
7171

72-
describe("getRequestScopedDapper", () => {
72+
describe("getDapperForRequest", () => {
7373
it("returns client dapper if no request is provided", () => {
7474
initializeClientDapper();
75-
const dapper = getRequestScopedDapper();
75+
const dapper = getDapperForRequest();
7676
expect(dapper).toBe(window.Dapper);
7777
});
7878

7979
it("returns a proxy if request is provided", () => {
8080
initializeClientDapper();
8181
const request = new Request("http://localhost");
82-
const dapper = getRequestScopedDapper(request);
82+
const dapper = getDapperForRequest(request);
8383
expect(dapper).not.toBe(window.Dapper);
8484
// It should be a proxy
8585
expect(dapper).toBeInstanceOf(DapperTs);
@@ -88,24 +88,24 @@ describe("dapperSingleton", () => {
8888
it("caches the proxy for the same request", () => {
8989
initializeClientDapper();
9090
const request = new Request("http://localhost");
91-
const dapper1 = getRequestScopedDapper(request);
92-
const dapper2 = getRequestScopedDapper(request);
91+
const dapper1 = getDapperForRequest(request);
92+
const dapper2 = getDapperForRequest(request);
9393
expect(dapper1).toBe(dapper2);
9494
});
9595

9696
it("creates different proxies for different requests", () => {
9797
initializeClientDapper();
9898
const request1 = new Request("http://localhost");
9999
const request2 = new Request("http://localhost");
100-
const dapper1 = getRequestScopedDapper(request1);
101-
const dapper2 = getRequestScopedDapper(request2);
100+
const dapper1 = getDapperForRequest(request1);
101+
const dapper2 = getDapperForRequest(request2);
102102
expect(dapper1).not.toBe(dapper2);
103103
});
104104

105105
it("intercepts 'get' methods and caches promises", async () => {
106106
initializeClientDapper();
107107
const request = new Request("http://localhost");
108-
const dapper = getRequestScopedDapper(request);
108+
const dapper = getDapperForRequest(request);
109109

110110
// Mock the underlying method on window.Dapper
111111
const mockGetCommunities = vi
@@ -125,7 +125,7 @@ describe("dapperSingleton", () => {
125125
it("does not intercept non-'get' methods", async () => {
126126
initializeClientDapper();
127127
const request = new Request("http://localhost");
128-
const dapper = getRequestScopedDapper(request);
128+
const dapper = getDapperForRequest(request);
129129

130130
// Mock a non-get method
131131
// postTeamCreate is a good candidate

apps/cyberstorm-remix/cyberstorm/utils/__tests__/requestCache.test.ts

Lines changed: 79 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import { describe, it, expect, vi } from "vitest";
2-
import { getCachedRequestPromise } from "../requestCache";
2+
import { deduplicatePromiseForRequest } from "../requestCache";
33

44
describe("requestCache", () => {
55
it("caches result per request", async () => {
66
const mockFn = vi.fn().mockResolvedValue("result");
77
const request = new Request("http://localhost");
88

9-
const result1 = await getCachedRequestPromise(
9+
const result1 = await deduplicatePromiseForRequest(
1010
"mockFn",
1111
mockFn,
1212
["arg1"],
1313
request
1414
);
15-
const result2 = await getCachedRequestPromise(
15+
const result2 = await deduplicatePromiseForRequest(
1616
"mockFn",
1717
mockFn,
1818
["arg1"],
@@ -24,13 +24,79 @@ describe("requestCache", () => {
2424
expect(mockFn).toHaveBeenCalledTimes(1);
2525
});
2626

27+
it("returns the exact same promise instance", async () => {
28+
const mockFn = vi.fn().mockResolvedValue("result");
29+
const request = new Request("http://localhost");
30+
31+
const promise1 = deduplicatePromiseForRequest(
32+
"mockFn",
33+
mockFn,
34+
["arg1"],
35+
request
36+
);
37+
const promise2 = deduplicatePromiseForRequest(
38+
"mockFn",
39+
mockFn,
40+
["arg1"],
41+
request
42+
);
43+
44+
expect(promise1).toBe(promise2);
45+
});
46+
47+
it("distinguishes between calls with different arguments", async () => {
48+
const mockFn = vi.fn().mockImplementation(async (arg) => arg);
49+
const request = new Request("http://localhost");
50+
51+
const result1 = await deduplicatePromiseForRequest(
52+
"mockFn",
53+
mockFn,
54+
["arg1"],
55+
request
56+
);
57+
const result2 = await deduplicatePromiseForRequest(
58+
"mockFn",
59+
mockFn,
60+
["arg2"],
61+
request
62+
);
63+
64+
expect(result1).toBe("arg1");
65+
expect(result2).toBe("arg2");
66+
expect(mockFn).toHaveBeenCalledTimes(2);
67+
});
68+
69+
it("removes rejected promises from cache", async () => {
70+
const mockFn = vi
71+
.fn()
72+
.mockRejectedValueOnce(new Error("fail"))
73+
.mockResolvedValueOnce("success");
74+
const request = new Request("http://localhost");
75+
76+
// First call fails
77+
await expect(
78+
deduplicatePromiseForRequest("mockFn", mockFn, ["arg1"], request)
79+
).rejects.toThrow("fail");
80+
81+
// Second call should retry and succeed
82+
const result = await deduplicatePromiseForRequest(
83+
"mockFn",
84+
mockFn,
85+
["arg1"],
86+
request
87+
);
88+
89+
expect(result).toBe("success");
90+
expect(mockFn).toHaveBeenCalledTimes(2);
91+
});
92+
2793
it("does not share cache between requests", async () => {
2894
const mockFn = vi.fn().mockResolvedValue("result");
2995
const request1 = new Request("http://localhost");
3096
const request2 = new Request("http://localhost");
3197

32-
await getCachedRequestPromise("mockFn", mockFn, ["arg1"], request1);
33-
await getCachedRequestPromise("mockFn", mockFn, ["arg1"], request2);
98+
await deduplicatePromiseForRequest("mockFn", mockFn, ["arg1"], request1);
99+
await deduplicatePromiseForRequest("mockFn", mockFn, ["arg1"], request2);
34100

35101
expect(mockFn).toHaveBeenCalledTimes(2);
36102
});
@@ -40,15 +106,15 @@ describe("requestCache", () => {
40106
const request = new Request("http://localhost");
41107

42108
expect(() =>
43-
getCachedRequestPromise("default", mockFn, [], request)
109+
deduplicatePromiseForRequest("default", mockFn, [], request)
44110
).toThrow("Must be named functions to support caching.");
45111
});
46112

47113
it("allows caching if a valid name is provided", async () => {
48114
const mockFn = async () => "result";
49115
const request = new Request("http://localhost");
50116

51-
const result = await getCachedRequestPromise(
117+
const result = await deduplicatePromiseForRequest(
52118
"customLabel",
53119
mockFn,
54120
[],
@@ -64,12 +130,12 @@ describe("requestCache", () => {
64130
signal: controller.signal,
65131
});
66132

67-
await getCachedRequestPromise("mockFn", mockFn, ["arg1"], request);
133+
await deduplicatePromiseForRequest("mockFn", mockFn, ["arg1"], request);
68134

69135
// Abort the request
70136
controller.abort();
71137

72-
await getCachedRequestPromise("mockFn", mockFn, ["arg1"], request);
138+
await deduplicatePromiseForRequest("mockFn", mockFn, ["arg1"], request);
73139

74140
expect(mockFn).toHaveBeenCalledTimes(2);
75141
});
@@ -84,16 +150,13 @@ describe("requestCache", () => {
84150
// Abort before first use
85151
controller.abort();
86152

87-
// First use: populates cache (unavoidable currently as we push after check)
88-
await getCachedRequestPromise("mockFn", mockFn, ["arg1"], request);
153+
// First use: should NOT cache (or cache is cleared immediately)
154+
await deduplicatePromiseForRequest("mockFn", mockFn, ["arg1"], request);
89155
expect(mockFn).toHaveBeenCalledTimes(1);
90156

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);
157+
// Second use: should be re-executed because cache was not persisted/cleared
158+
await deduplicatePromiseForRequest("mockFn", mockFn, ["arg1"], request);
94159

95-
// If consistent with above, it should be 2.
96-
// If inconsistent (bug), it is 1.
97160
expect(mockFn).toHaveBeenCalledTimes(2);
98161
});
99162
});

apps/cyberstorm-remix/cyberstorm/utils/dapperSingleton.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { DapperTs } from "@thunderstore/dapper-ts";
22
import { type RequestConfig } from "@thunderstore/thunderstore-api";
33
import { getSessionTools } from "../security/publicEnvVariables";
4-
import { getCachedRequestPromise } from "./requestCache";
4+
import { deduplicatePromiseForRequest } from "./requestCache";
55

66
type ConfigFactory = () => RequestConfig;
77

@@ -54,7 +54,14 @@ export function getClientDapper(): DapperTs {
5454
return window.Dapper;
5555
}
5656

57-
export function getRequestScopedDapper(request?: Request): DapperTs {
57+
/**
58+
* Returns a Dapper instance scoped to the request.
59+
* If a request is provided, it returns a proxy that deduplicates "get" requests.
60+
* If no request is provided (e.g. client-side navigation), it returns the global client Dapper instance.
61+
*
62+
* @param request - The Request object to scope the Dapper instance to.
63+
*/
64+
export function getDapperForRequest(request?: Request): DapperTs {
5865
if (!request) {
5966
return getClientDapper();
6067
}
@@ -72,12 +79,13 @@ export function getRequestScopedDapper(request?: Request): DapperTs {
7279
return value;
7380
}
7481

82+
// We only cache methods starting with "get" to avoid caching mutations or other side effects.
7583
if (typeof propertyName !== "string" || !propertyName.startsWith("get")) {
7684
return value.bind(target);
7785
}
7886

7987
return (...args: unknown[]) =>
80-
getCachedRequestPromise(
88+
deduplicatePromiseForRequest(
8189
propertyName,
8290
(...innerArgs: unknown[]) =>
8391
(value as (...i: unknown[]) => Promise<unknown>).apply(

apps/cyberstorm-remix/cyberstorm/utils/requestCache.ts

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,36 +9,43 @@ type CacheEntry = {
99
};
1010

1111
const requestScopedCaches = new WeakMap<Request, CacheEntry[]>();
12+
1213
function getCache(request: Request) {
14+
if (request.signal?.aborted) {
15+
requestScopedCaches.delete(request);
16+
return [];
17+
}
18+
1319
let cache = requestScopedCaches.get(request);
1420
if (!cache) {
1521
cache = [];
1622
requestScopedCaches.set(request, cache);
1723

1824
const signal = request.signal;
1925
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-
}
26+
signal.addEventListener(
27+
"abort",
28+
() => {
29+
requestScopedCaches.delete(request);
30+
},
31+
{ once: true }
32+
);
3133
}
3234
}
3335

34-
if (request.signal?.aborted) {
35-
cache.splice(0, cache.length);
36-
}
37-
3836
return cache;
3937
}
4038

41-
export function getCachedRequestPromise<Args extends unknown[], Result>(
39+
/**
40+
* Deduplicates concurrent requests for the same function and arguments within the scope of a single Request.
41+
* This is useful for clientLoaders where we might trigger the same data fetch multiple times.
42+
*
43+
* @param funcName - The name of the function (used as part of the cache key). Must not be empty, "anonymous", or "default".
44+
* @param func - The function to execute.
45+
* @param inputs - The arguments to pass to the function.
46+
* @param request - The Request object to scope the cache to.
47+
*/
48+
export function deduplicatePromiseForRequest<Args extends unknown[], Result>(
4249
funcName: string,
4350
func: (...inputs: Args) => Promise<Result>,
4451
inputs: Args,
@@ -48,6 +55,9 @@ export function getCachedRequestPromise<Args extends unknown[], Result>(
4855
throw new Error("Must be named functions to support caching.");
4956
}
5057

58+
// Performance note: We use a linear search over the cache entries.
59+
// Since the number of unique requests per page load is usually small, this is acceptable.
60+
// We use lodash/isEqual for deep comparison of arguments, which can be expensive for large objects.
5161
const cache = getCache(request);
5262
for (const cacheEntry of cache) {
5363
if (
@@ -59,8 +69,22 @@ export function getCachedRequestPromise<Args extends unknown[], Result>(
5969
}
6070

6171
const promise = func(...inputs) as Promise<Result>;
72+
73+
// Remove from cache if it rejects to allow retrying
74+
promise.catch(() => {
75+
const currentCache = requestScopedCaches.get(request);
76+
if (currentCache) {
77+
const index = currentCache.findIndex(
78+
(entry) => entry.promise === promise
79+
);
80+
if (index !== -1) {
81+
currentCache.splice(index, 1);
82+
}
83+
}
84+
});
85+
6286
cache.push({
63-
funcName: funcName,
87+
funcName,
6488
inputs,
6589
promise,
6690
});

0 commit comments

Comments
 (0)