Skip to content

Commit 30af700

Browse files
committed
feat(clerk-js): Dedupe getToken requests
1 parent 6ea39ae commit 30af700

File tree

11 files changed

+1417
-232
lines changed

11 files changed

+1417
-232
lines changed

.changeset/every-dryers-refuse.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/types': minor
4+
---
5+
6+
When fetching a new Session token, broadcast the token value to other tabs so they can pre-warm their in-memory Session Token cache with the most recent token.

.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = `
177177
"shared/is-valid-browser.mdx",
178178
"shared/isomorphic-atob.mdx",
179179
"shared/load-clerk-js-script.mdx",
180+
"shared/local-storage-broadcast-channel.mdx",
180181
"shared/pages-or-infinite-options.mdx",
181182
"shared/paginated-hook-config.mdx",
182183
"shared/paginated-resources.mdx",
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import { appConfigs } from '../../presets';
4+
import type { FakeUser } from '../../testUtils';
5+
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
6+
7+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
8+
'MemoryTokenCache Multi-Session Integration @nextjs',
9+
({ app }) => {
10+
test.describe.configure({ mode: 'serial' });
11+
12+
let fakeUser1: FakeUser;
13+
let fakeUser2: FakeUser;
14+
15+
test.beforeAll(async () => {
16+
const u = createTestUtils({ app });
17+
fakeUser1 = u.services.users.createFakeUser();
18+
fakeUser2 = u.services.users.createFakeUser();
19+
await u.services.users.createBapiUser(fakeUser1);
20+
await u.services.users.createBapiUser(fakeUser2);
21+
});
22+
23+
test.afterAll(async () => {
24+
await fakeUser1.deleteIfExists();
25+
await fakeUser2.deleteIfExists();
26+
await app.teardown();
27+
});
28+
29+
test('MemoryTokenCache multi-session - multiple users in different tabs with separate token caches', async ({
30+
context,
31+
}) => {
32+
const page1 = await context.newPage();
33+
await page1.goto(app.serverUrl);
34+
await page1.waitForFunction(() => (window as any).Clerk?.loaded);
35+
36+
const u1 = createTestUtils({ app, page: page1 });
37+
await u1.po.signIn.goTo();
38+
await u1.po.signIn.setIdentifier(fakeUser1.email);
39+
await u1.po.signIn.continue();
40+
await u1.po.signIn.setPassword(fakeUser1.password);
41+
await u1.po.signIn.continue();
42+
await u1.po.expect.toBeSignedIn();
43+
44+
const user1SessionInfo = await page1.evaluate(() => {
45+
const clerk = (window as any).Clerk;
46+
return {
47+
sessionId: clerk?.session?.id,
48+
userId: clerk?.user?.id,
49+
};
50+
});
51+
52+
expect(user1SessionInfo.sessionId).toBeDefined();
53+
expect(user1SessionInfo.userId).toBeDefined();
54+
55+
const user1Token = await page1.evaluate(async () => {
56+
const clerk = (window as any).Clerk;
57+
return await clerk.session?.getToken({ skipCache: true });
58+
});
59+
60+
expect(user1Token).toBeTruthy();
61+
62+
const page2 = await context.newPage();
63+
await page2.goto(app.serverUrl);
64+
await page2.waitForFunction(() => (window as any).Clerk?.loaded);
65+
66+
// eslint-disable-next-line playwright/no-wait-for-timeout
67+
await page2.waitForTimeout(1000);
68+
69+
const u2 = createTestUtils({ app, page: page2 });
70+
await u2.po.expect.toBeSignedIn();
71+
72+
const page2User1SessionInfo = await page2.evaluate(() => {
73+
const clerk = (window as any).Clerk;
74+
return {
75+
sessionId: clerk?.session?.id,
76+
userId: clerk?.user?.id,
77+
};
78+
});
79+
80+
expect(page2User1SessionInfo.userId).toBe(user1SessionInfo.userId);
81+
expect(page2User1SessionInfo.sessionId).toBe(user1SessionInfo.sessionId);
82+
83+
// Use clerk.client.signIn.create() instead of navigating to /sign-in
84+
// because navigating replaces the session by default (transferable: true)
85+
const signInResult = await page2.evaluate(
86+
async ({ email, password }) => {
87+
const clerk = (window as any).Clerk;
88+
89+
try {
90+
const signIn = await clerk.client.signIn.create({
91+
identifier: email,
92+
password: password,
93+
});
94+
95+
await clerk.setActive({
96+
session: signIn.createdSessionId,
97+
});
98+
99+
return {
100+
allSessions: clerk?.client?.sessions?.map((s: any) => ({ id: s.id, userId: s.userId })) || [],
101+
sessionCount: clerk?.client?.sessions?.length || 0,
102+
success: true,
103+
};
104+
} catch (error: any) {
105+
return {
106+
error: error.message || String(error),
107+
success: false,
108+
};
109+
}
110+
},
111+
{ email: fakeUser2.email, password: fakeUser2.password },
112+
);
113+
114+
expect(signInResult.success).toBe(true);
115+
expect(signInResult.sessionCount).toBe(2);
116+
117+
await u2.po.expect.toBeSignedIn();
118+
119+
const user2SessionInfo = await page2.evaluate(() => {
120+
const clerk = (window as any).Clerk;
121+
return {
122+
allSessions: clerk?.client?.sessions?.map((s: any) => ({ id: s.id, userId: s.userId })) || [],
123+
sessionCount: clerk?.client?.sessions?.length || 0,
124+
sessionId: clerk?.session?.id,
125+
userId: clerk?.user?.id,
126+
};
127+
});
128+
129+
expect(user2SessionInfo.sessionId).toBeDefined();
130+
expect(user2SessionInfo.userId).toBeDefined();
131+
expect(user2SessionInfo.sessionId).not.toBe(user1SessionInfo.sessionId);
132+
expect(user2SessionInfo.userId).not.toBe(user1SessionInfo.userId);
133+
134+
const user2Token = await page2.evaluate(async () => {
135+
const clerk = (window as any).Clerk;
136+
return await clerk.session?.getToken({ skipCache: true });
137+
});
138+
139+
expect(user2Token).toBeTruthy();
140+
expect(user2Token).not.toBe(user1Token);
141+
142+
const page2MultiSessionInfo = await page2.evaluate(() => {
143+
const clerk = (window as any).Clerk;
144+
return {
145+
activeSessionId: clerk?.session?.id,
146+
allSessionIds: clerk?.client?.sessions?.map((s: any) => s.id) || [],
147+
sessionCount: clerk?.client?.sessions?.length || 0,
148+
};
149+
});
150+
151+
expect(page2MultiSessionInfo.sessionCount).toBe(2);
152+
expect(page2MultiSessionInfo.allSessionIds).toContain(user1SessionInfo.sessionId);
153+
expect(page2MultiSessionInfo.allSessionIds).toContain(user2SessionInfo.sessionId);
154+
expect(page2MultiSessionInfo.activeSessionId).toBe(user2SessionInfo.sessionId);
155+
156+
const tokenFetchRequests: Array<{ sessionId: string; url: string }> = [];
157+
await context.route('**/v1/client/sessions/*/tokens*', async route => {
158+
const url = route.request().url();
159+
const sessionIdMatch = url.match(/sessions\/([^/]+)\/tokens/);
160+
const sessionId = sessionIdMatch?.[1] || 'unknown';
161+
tokenFetchRequests.push({ sessionId, url });
162+
await route.continue();
163+
});
164+
165+
const tokenIsolation = await page2.evaluate(
166+
async ({ user1SessionId, user2SessionId }) => {
167+
const clerk = (window as any).Clerk;
168+
169+
await clerk.setActive({ session: user1SessionId });
170+
const user1Token = await clerk.session?.getToken();
171+
172+
await clerk.setActive({ session: user2SessionId });
173+
const user2Token = await clerk.session?.getToken();
174+
175+
return {
176+
tokensAreDifferent: user1Token !== user2Token,
177+
user1Token,
178+
user2Token,
179+
};
180+
},
181+
{ user1SessionId: user1SessionInfo.sessionId, user2SessionId: user2SessionInfo.sessionId },
182+
);
183+
184+
expect(tokenIsolation.tokensAreDifferent).toBe(true);
185+
expect(tokenIsolation.user1Token).toBeTruthy();
186+
expect(tokenIsolation.user2Token).toBeTruthy();
187+
expect(tokenFetchRequests.length).toBe(0);
188+
189+
await context.unroute('**/v1/client/sessions/*/tokens*');
190+
191+
// In multi-session apps, each tab can have a different active session
192+
const tab1FinalInfo = await page1.evaluate(() => {
193+
const clerk = (window as any).Clerk;
194+
return {
195+
activeSessionId: clerk?.session?.id,
196+
userId: clerk?.user?.id,
197+
};
198+
});
199+
200+
// Tab1 should STILL have user1 as the active session (independent per tab)
201+
expect(tab1FinalInfo.userId).toBe(user1SessionInfo.userId);
202+
expect(tab1FinalInfo.activeSessionId).toBe(user1SessionInfo.sessionId);
203+
});
204+
},
205+
);
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import { appConfigs } from '../../presets';
4+
import type { FakeUser } from '../../testUtils';
5+
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
6+
7+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })(
8+
'MemoryTokenCache Multi-Tab Integration @generic',
9+
({ app }) => {
10+
test.describe.configure({ mode: 'serial' });
11+
12+
let fakeUser: FakeUser;
13+
14+
test.beforeAll(async () => {
15+
const u = createTestUtils({ app });
16+
fakeUser = u.services.users.createFakeUser({
17+
withPhoneNumber: true,
18+
withUsername: true,
19+
});
20+
await u.services.users.createBapiUser(fakeUser);
21+
});
22+
23+
test.afterAll(async () => {
24+
await fakeUser.deleteIfExists();
25+
await app.teardown();
26+
});
27+
28+
test('MemoryTokenCache multi-tab token sharing', async ({ context }) => {
29+
const page1 = await context.newPage();
30+
const page2 = await context.newPage();
31+
32+
await page1.goto(app.serverUrl);
33+
await page2.goto(app.serverUrl);
34+
35+
await page1.waitForFunction(() => (window as any).Clerk?.loaded);
36+
await page2.waitForFunction(() => (window as any).Clerk?.loaded);
37+
38+
const u1 = createTestUtils({ app, page: page1 });
39+
await u1.po.signIn.goTo();
40+
await u1.po.signIn.setIdentifier(fakeUser.email);
41+
await u1.po.signIn.continue();
42+
await u1.po.signIn.setPassword(fakeUser.password);
43+
await u1.po.signIn.continue();
44+
await u1.po.expect.toBeSignedIn();
45+
46+
// eslint-disable-next-line playwright/no-wait-for-timeout
47+
await page1.waitForTimeout(1000);
48+
49+
await page2.reload();
50+
await page2.waitForFunction(() => (window as any).Clerk?.loaded);
51+
52+
const u2 = createTestUtils({ app, page: page2 });
53+
await u2.po.expect.toBeSignedIn();
54+
55+
const page1SessionInfo = await page1.evaluate(() => {
56+
const clerk = (window as any).Clerk;
57+
return {
58+
sessionId: clerk?.session?.id,
59+
userId: clerk?.user?.id,
60+
};
61+
});
62+
63+
expect(page1SessionInfo.sessionId).toBeDefined();
64+
expect(page1SessionInfo.userId).toBeDefined();
65+
66+
await Promise.all([
67+
page1.evaluate(() => (window as any).Clerk.session?.clearCache()),
68+
page2.evaluate(() => (window as any).Clerk.session?.clearCache()),
69+
]);
70+
71+
// Track token fetch requests to verify only one network call happens
72+
const tokenRequests: string[] = [];
73+
await context.route('**/v1/client/sessions/*/tokens*', async route => {
74+
tokenRequests.push(route.request().url());
75+
await route.continue();
76+
});
77+
78+
const page1Token = await page1.evaluate(async () => {
79+
const clerk = (window as any).Clerk;
80+
return await clerk.session?.getToken({ skipCache: true });
81+
});
82+
83+
expect(page1Token).toBeTruthy();
84+
85+
// Wait for broadcast to propagate between tabs (broadcast is nearly instant, but we add buffer)
86+
// eslint-disable-next-line playwright/no-wait-for-timeout
87+
await page2.waitForTimeout(2000);
88+
89+
const page2Result = await page2.evaluate(async () => {
90+
const clerk = (window as any).Clerk;
91+
92+
const token = await clerk.session?.getToken();
93+
94+
return {
95+
sessionId: clerk?.session?.id,
96+
token,
97+
userId: clerk?.user?.id,
98+
};
99+
});
100+
101+
expect(page2Result.sessionId).toBe(page1SessionInfo.sessionId);
102+
expect(page2Result.userId).toBe(page1SessionInfo.userId);
103+
104+
// If BroadcastChannel worked, both tabs should have the EXACT same token
105+
expect(page2Result.token).toBe(page1Token);
106+
107+
// Verify only one token fetch happened (page1), proving page2 got it from BroadcastChannel
108+
expect(tokenRequests.length).toBe(1);
109+
});
110+
},
111+
);

0 commit comments

Comments
 (0)