Skip to content

Commit 41c60ce

Browse files
authored
Merge pull request #1636 from thunderstore-io/11-20-test_implemenation_of_cross-clientloader-request_dedupe
Request caching
2 parents a25273f + 13b25b7 commit 41c60ce

File tree

6 files changed

+606
-3
lines changed

6 files changed

+606
-3
lines changed

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@ import { hydrateRoot } from "react-dom/client";
44
import { useLocation, useMatches } from "react-router";
55
import { HydratedRouter } from "react-router/dom";
66

7-
import { getPublicEnvVariables } from "cyberstorm/security/publicEnvVariables";
7+
import {
8+
getPublicEnvVariables,
9+
getSessionTools,
10+
} from "cyberstorm/security/publicEnvVariables";
811
import { denyUrls } from "cyberstorm/utils/sentry";
12+
import { initializeClientDapper } from "cyberstorm/utils/dapperSingleton";
913

1014
const publicEnvVariables = getPublicEnvVariables([
1115
"VITE_SITE_URL",
1216
"VITE_BETA_SITE_URL",
1317
"VITE_API_URL",
1418
"VITE_AUTH_BASE_URL",
1519
"VITE_CLIENT_SENTRY_DSN",
20+
"VITE_COOKIE_DOMAIN",
1621
]);
1722

1823
Sentry.init({
@@ -69,6 +74,16 @@ Sentry.init({
6974
denyUrls,
7075
});
7176

77+
try {
78+
const sessionTools = getSessionTools();
79+
80+
initializeClientDapper(() =>
81+
sessionTools.getConfig(publicEnvVariables.VITE_API_URL)
82+
);
83+
} catch (error) {
84+
Sentry.captureException(error);
85+
}
86+
7287
startTransition(() => {
7388
hydrateRoot(
7489
document,

apps/cyberstorm-remix/cyberstorm/security/publicEnvVariables.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getSessionContext } from "@thunderstore/ts-api-react/src/SessionContext";
2-
import { isRecord } from "cyberstorm/utils/typeChecks";
2+
import { isRecord } from "../utils/typeChecks";
33

44
export type publicEnvVariablesKeys =
55
| "SITE_URL"
@@ -57,7 +57,7 @@ export function getSessionTools() {
5757
!publicEnvVariables.VITE_COOKIE_DOMAIN
5858
) {
5959
throw new Error(
60-
"Enviroment variables did not load correctly, please hard refresh page"
60+
"Environment variables did not load correctly, please hard refresh page"
6161
);
6262
}
6363
return getSessionContext(
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import {
3+
initializeClientDapper,
4+
getClientDapper,
5+
getDapperForRequest,
6+
resetDapperSingletonForTest,
7+
} from "../dapperSingleton";
8+
import { deduplicatePromiseForRequest } from "../requestCache";
9+
import { DapperTs } from "@thunderstore/dapper-ts";
10+
import * as publicEnvVariables from "../../security/publicEnvVariables";
11+
import type { Community } from "../../../../../packages/thunderstore-api/src";
12+
13+
// Mock getSessionTools
14+
vi.mock("../../security/publicEnvVariables", () => ({
15+
getSessionTools: vi.fn().mockReturnValue({
16+
getConfig: vi.fn().mockReturnValue({
17+
apiHost: "http://localhost",
18+
sessionId: "test-session",
19+
}),
20+
}),
21+
}));
22+
23+
describe("dapperSingleton", () => {
24+
beforeEach(() => {
25+
// Reset window.Dapper
26+
if (typeof window !== "undefined") {
27+
// @ts-expect-error Dapper is not optional on Window
28+
delete window.Dapper;
29+
}
30+
resetDapperSingletonForTest();
31+
vi.clearAllMocks();
32+
});
33+
34+
afterEach(() => {
35+
vi.restoreAllMocks();
36+
});
37+
38+
describe("initializeClientDapper", () => {
39+
it("initializes window.Dapper if it does not exist", () => {
40+
initializeClientDapper();
41+
expect(window.Dapper).toBeDefined();
42+
expect(window.Dapper).toBeInstanceOf(DapperTs);
43+
});
44+
45+
it("uses provided factory if supplied", () => {
46+
const factory = vi.fn().mockReturnValue({ apiHost: "custom" });
47+
initializeClientDapper(factory);
48+
expect(window.Dapper).toBeDefined();
49+
expect(window.Dapper.config()).toEqual({ apiHost: "custom" });
50+
expect(factory).toHaveBeenCalled();
51+
});
52+
53+
it("updates existing window.Dapper config if called again with factory", () => {
54+
// First initialization
55+
initializeClientDapper();
56+
const originalDapper = window.Dapper;
57+
expect(originalDapper).toBeDefined();
58+
59+
// Second initialization with new factory
60+
const newFactory = vi.fn().mockReturnValue({ apiHost: "updated" });
61+
initializeClientDapper(newFactory);
62+
63+
expect(window.Dapper).toBe(originalDapper); // Should be same instance
64+
expect(window.Dapper.config()).toEqual({ apiHost: "updated" });
65+
});
66+
67+
it("resolves config factory from session tools if no factory provided", () => {
68+
initializeClientDapper();
69+
expect(publicEnvVariables.getSessionTools).toHaveBeenCalled();
70+
});
71+
});
72+
73+
describe("getClientDapper", () => {
74+
it("returns window.Dapper if it exists", () => {
75+
initializeClientDapper();
76+
const dapper = window.Dapper;
77+
expect(getClientDapper()).toBe(dapper);
78+
});
79+
80+
it("initializes and returns window.Dapper if it does not exist", () => {
81+
expect(window.Dapper).toBeUndefined();
82+
const dapper = getClientDapper();
83+
expect(dapper).toBeDefined();
84+
expect(window.Dapper).toBe(dapper);
85+
});
86+
});
87+
88+
describe("getDapperForRequest", () => {
89+
it("returns client dapper if no request is provided", () => {
90+
initializeClientDapper();
91+
const dapper = getDapperForRequest();
92+
expect(dapper).toBe(window.Dapper);
93+
});
94+
95+
it("returns a proxy if request is provided", () => {
96+
initializeClientDapper();
97+
const request = new Request("http://localhost");
98+
const dapper = getDapperForRequest(request);
99+
expect(dapper).not.toBe(window.Dapper);
100+
// It should be a proxy
101+
expect(dapper).toBeInstanceOf(DapperTs);
102+
});
103+
104+
it("caches the proxy for the same request", () => {
105+
initializeClientDapper();
106+
const request = new Request("http://localhost");
107+
const dapper1 = getDapperForRequest(request);
108+
const dapper2 = getDapperForRequest(request);
109+
expect(dapper1).toBe(dapper2);
110+
});
111+
112+
it("creates different proxies for different requests", () => {
113+
initializeClientDapper();
114+
const request1 = new Request("http://localhost");
115+
const request2 = new Request("http://localhost");
116+
const dapper1 = getDapperForRequest(request1);
117+
const dapper2 = getDapperForRequest(request2);
118+
expect(dapper1).not.toBe(dapper2);
119+
});
120+
121+
it("intercepts 'get' methods and caches promises", async () => {
122+
initializeClientDapper();
123+
const request = new Request("http://localhost");
124+
const dapper = getDapperForRequest(request);
125+
126+
// Mock the underlying method on window.Dapper
127+
const mockGetCommunities = vi
128+
.spyOn(window.Dapper, "getCommunities")
129+
.mockResolvedValue({ count: 0, results: [], hasMore: false });
130+
131+
const result1 = await dapper.getCommunities();
132+
const result2 = await dapper.getCommunities();
133+
134+
expect(result1).toEqual({ count: 0, results: [], hasMore: false });
135+
expect(result2).toEqual({ count: 0, results: [], hasMore: false });
136+
137+
// Should be called only once due to caching
138+
expect(mockGetCommunities).toHaveBeenCalledTimes(1);
139+
});
140+
141+
it("does not intercept non-'get' methods", async () => {
142+
initializeClientDapper();
143+
const request = new Request("http://localhost");
144+
const dapper = getDapperForRequest(request);
145+
146+
// Mock a non-get method
147+
// postTeamCreate is a good candidate
148+
const mockPostTeamCreate = vi
149+
.spyOn(window.Dapper, "postTeamCreate")
150+
.mockResolvedValue({
151+
identifier: 1,
152+
name: "test",
153+
donation_link: null,
154+
});
155+
156+
await dapper.postTeamCreate("test");
157+
await dapper.postTeamCreate("test");
158+
159+
// Should be called twice (no caching)
160+
expect(mockPostTeamCreate).toHaveBeenCalledTimes(2);
161+
});
162+
163+
it("shares cache between proxy calls and manual deduplicatePromiseForRequest calls", async () => {
164+
initializeClientDapper();
165+
const request = new Request("http://localhost");
166+
const dapper = getDapperForRequest(request);
167+
168+
// Mock the underlying method on window.Dapper
169+
const mockGetCommunity = vi
170+
.spyOn(window.Dapper, "getCommunity")
171+
.mockResolvedValue({
172+
identifier: "1",
173+
name: "Test Community",
174+
} as Community);
175+
176+
// 1. Call via proxy
177+
const dapperResult = await dapper.getCommunity("1");
178+
179+
// 2. Call manually with same key and args
180+
const manualFunc = vi.fn().mockResolvedValue("manual result");
181+
const manualResult = await deduplicatePromiseForRequest(
182+
"getCommunity",
183+
manualFunc,
184+
["1"],
185+
request
186+
);
187+
188+
// Assertions
189+
expect(dapperResult).toEqual({ identifier: "1", name: "Test Community" });
190+
// Should return the cached result from the first call, NOT "manual result"
191+
expect(manualResult).toBe(dapperResult);
192+
// The manual function should NOT have been called
193+
expect(manualFunc).not.toHaveBeenCalled();
194+
// The underlying dapper method should have been called once
195+
expect(mockGetCommunity).toHaveBeenCalledTimes(1);
196+
});
197+
});
198+
});

0 commit comments

Comments
 (0)