Skip to content

Commit 60a7e8f

Browse files
committed
feat(clerk-js): Dedupe getToken requests
1 parent 04cba7d commit 60a7e8f

File tree

15 files changed

+1800
-229
lines changed

15 files changed

+1800
-229
lines changed

.changeset/every-dryers-refuse.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
---
4+
5+
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.

.changeset/slick-ducks-bet.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/shared': patch
3+
---
4+
5+
Introduce deprecation warning for LocalStorageBroadcastChannel

.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: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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+
/**
8+
* Tests MemoryTokenCache session isolation in multi-session scenarios
9+
*
10+
* This suite validates that when multiple user sessions exist simultaneously,
11+
* each session maintains its own isolated token cache. Tokens are not shared
12+
* between different sessions, even within the same tab, ensuring proper
13+
* security boundaries between users.
14+
*/
15+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
16+
'MemoryTokenCache Multi-Session Integration @nextjs',
17+
({ app }) => {
18+
test.describe.configure({ mode: 'serial' });
19+
20+
let fakeUser1: FakeUser;
21+
let fakeUser2: FakeUser;
22+
23+
test.beforeAll(async () => {
24+
const u = createTestUtils({ app });
25+
fakeUser1 = u.services.users.createFakeUser();
26+
fakeUser2 = u.services.users.createFakeUser();
27+
await u.services.users.createBapiUser(fakeUser1);
28+
await u.services.users.createBapiUser(fakeUser2);
29+
});
30+
31+
test.afterAll(async () => {
32+
await fakeUser1.deleteIfExists();
33+
await fakeUser2.deleteIfExists();
34+
await app.teardown();
35+
});
36+
37+
/**
38+
* Test Flow:
39+
* 1. Tab1: Sign in as user1, fetch and cache their token
40+
* 2. Tab2: Opens and inherits user1's session via cookies
41+
* 3. Tab2: Sign in as user2 using programmatic sign-in (preserves both sessions)
42+
* 4. Tab2: Now has two active sessions (user1 and user2)
43+
* 5. Tab2: Switch between sessions and fetch tokens for each
44+
* 6. Verify no network requests occur (tokens served from cache)
45+
* 7. Tab1: Verify it still has user1 as active session (tab independence)
46+
*
47+
* Expected Behavior:
48+
* - Each session has its own isolated token cache
49+
* - Switching sessions in tab2 returns different tokens
50+
* - Both tokens are served from cache (no network requests)
51+
* - Tab1 remains unaffected by tab2's session changes
52+
* - Multi-session state is properly maintained per-tab
53+
*/
54+
test('MemoryTokenCache multi-session - multiple users in different tabs with separate token caches', async ({
55+
context,
56+
}) => {
57+
const page1 = await context.newPage();
58+
await page1.goto(app.serverUrl);
59+
await page1.waitForFunction(() => (window as any).Clerk?.loaded);
60+
61+
const u1 = createTestUtils({ app, page: page1 });
62+
await u1.po.signIn.goTo();
63+
await u1.po.signIn.setIdentifier(fakeUser1.email);
64+
await u1.po.signIn.continue();
65+
await u1.po.signIn.setPassword(fakeUser1.password);
66+
await u1.po.signIn.continue();
67+
await u1.po.expect.toBeSignedIn();
68+
69+
const user1SessionInfo = await page1.evaluate(() => {
70+
const clerk = (window as any).Clerk;
71+
return {
72+
sessionId: clerk?.session?.id,
73+
userId: clerk?.user?.id,
74+
};
75+
});
76+
77+
expect(user1SessionInfo.sessionId).toBeDefined();
78+
expect(user1SessionInfo.userId).toBeDefined();
79+
80+
const user1Token = await page1.evaluate(async () => {
81+
const clerk = (window as any).Clerk;
82+
return await clerk.session?.getToken({ skipCache: true });
83+
});
84+
85+
expect(user1Token).toBeTruthy();
86+
87+
const page2 = await context.newPage();
88+
await page2.goto(app.serverUrl);
89+
await page2.waitForFunction(() => (window as any).Clerk?.loaded);
90+
91+
// eslint-disable-next-line playwright/no-wait-for-timeout
92+
await page2.waitForTimeout(1000);
93+
94+
const u2 = createTestUtils({ app, page: page2 });
95+
await u2.po.expect.toBeSignedIn();
96+
97+
const page2User1SessionInfo = await page2.evaluate(() => {
98+
const clerk = (window as any).Clerk;
99+
return {
100+
sessionId: clerk?.session?.id,
101+
userId: clerk?.user?.id,
102+
};
103+
});
104+
105+
expect(page2User1SessionInfo.userId).toBe(user1SessionInfo.userId);
106+
expect(page2User1SessionInfo.sessionId).toBe(user1SessionInfo.sessionId);
107+
108+
// Use clerk.client.signIn.create() instead of navigating to /sign-in
109+
// because navigating replaces the session by default (transferable: true)
110+
const signInResult = await page2.evaluate(
111+
async ({ email, password }) => {
112+
const clerk = (window as any).Clerk;
113+
114+
try {
115+
const signIn = await clerk.client.signIn.create({
116+
identifier: email,
117+
password: password,
118+
});
119+
120+
await clerk.setActive({
121+
session: signIn.createdSessionId,
122+
});
123+
124+
return {
125+
allSessions: clerk?.client?.sessions?.map((s: any) => ({ id: s.id, userId: s.userId })) || [],
126+
sessionCount: clerk?.client?.sessions?.length || 0,
127+
success: true,
128+
};
129+
} catch (error: any) {
130+
return {
131+
error: error.message || String(error),
132+
success: false,
133+
};
134+
}
135+
},
136+
{ email: fakeUser2.email, password: fakeUser2.password },
137+
);
138+
139+
expect(signInResult.success).toBe(true);
140+
expect(signInResult.sessionCount).toBe(2);
141+
142+
await u2.po.expect.toBeSignedIn();
143+
144+
const user2SessionInfo = await page2.evaluate(() => {
145+
const clerk = (window as any).Clerk;
146+
return {
147+
allSessions: clerk?.client?.sessions?.map((s: any) => ({ id: s.id, userId: s.userId })) || [],
148+
sessionCount: clerk?.client?.sessions?.length || 0,
149+
sessionId: clerk?.session?.id,
150+
userId: clerk?.user?.id,
151+
};
152+
});
153+
154+
expect(user2SessionInfo.sessionId).toBeDefined();
155+
expect(user2SessionInfo.userId).toBeDefined();
156+
expect(user2SessionInfo.sessionId).not.toBe(user1SessionInfo.sessionId);
157+
expect(user2SessionInfo.userId).not.toBe(user1SessionInfo.userId);
158+
159+
const user2Token = await page2.evaluate(async () => {
160+
const clerk = (window as any).Clerk;
161+
return await clerk.session?.getToken({ skipCache: true });
162+
});
163+
164+
expect(user2Token).toBeTruthy();
165+
expect(user2Token).not.toBe(user1Token);
166+
167+
const page2MultiSessionInfo = await page2.evaluate(() => {
168+
const clerk = (window as any).Clerk;
169+
return {
170+
activeSessionId: clerk?.session?.id,
171+
allSessionIds: clerk?.client?.sessions?.map((s: any) => s.id) || [],
172+
sessionCount: clerk?.client?.sessions?.length || 0,
173+
};
174+
});
175+
176+
expect(page2MultiSessionInfo.sessionCount).toBe(2);
177+
expect(page2MultiSessionInfo.allSessionIds).toContain(user1SessionInfo.sessionId);
178+
expect(page2MultiSessionInfo.allSessionIds).toContain(user2SessionInfo.sessionId);
179+
expect(page2MultiSessionInfo.activeSessionId).toBe(user2SessionInfo.sessionId);
180+
181+
const tokenFetchRequests: Array<{ sessionId: string; url: string }> = [];
182+
await context.route('**/v1/client/sessions/*/tokens*', async route => {
183+
const url = route.request().url();
184+
const sessionIdMatch = url.match(/sessions\/([^/]+)\/tokens/);
185+
const sessionId = sessionIdMatch?.[1] || 'unknown';
186+
tokenFetchRequests.push({ sessionId, url });
187+
await route.continue();
188+
});
189+
190+
const tokenIsolation = await page2.evaluate(
191+
async ({ user1SessionId, user2SessionId }) => {
192+
const clerk = (window as any).Clerk;
193+
194+
await clerk.setActive({ session: user1SessionId });
195+
const user1Token = await clerk.session?.getToken();
196+
197+
await clerk.setActive({ session: user2SessionId });
198+
const user2Token = await clerk.session?.getToken();
199+
200+
return {
201+
tokensAreDifferent: user1Token !== user2Token,
202+
user1Token,
203+
user2Token,
204+
};
205+
},
206+
{ user1SessionId: user1SessionInfo.sessionId, user2SessionId: user2SessionInfo.sessionId },
207+
);
208+
209+
expect(tokenIsolation.tokensAreDifferent).toBe(true);
210+
expect(tokenIsolation.user1Token).toBeTruthy();
211+
expect(tokenIsolation.user2Token).toBeTruthy();
212+
expect(tokenFetchRequests.length).toBe(0);
213+
214+
await context.unroute('**/v1/client/sessions/*/tokens*');
215+
216+
// In multi-session apps, each tab can have a different active session
217+
const tab1FinalInfo = await page1.evaluate(() => {
218+
const clerk = (window as any).Clerk;
219+
return {
220+
activeSessionId: clerk?.session?.id,
221+
userId: clerk?.user?.id,
222+
};
223+
});
224+
225+
// Tab1 should STILL have user1 as the active session (independent per tab)
226+
expect(tab1FinalInfo.userId).toBe(user1SessionInfo.userId);
227+
expect(tab1FinalInfo.activeSessionId).toBe(user1SessionInfo.sessionId);
228+
});
229+
},
230+
);
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
/**
8+
* Tests MemoryTokenCache cross-tab token sharing via BroadcastChannel
9+
*
10+
* This suite validates that when multiple browser tabs share the same user session,
11+
* token fetches in one tab are automatically broadcast and cached in other tabs,
12+
* eliminating redundant network requests.
13+
*/
14+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })(
15+
'MemoryTokenCache Multi-Tab Integration @generic',
16+
({ app }) => {
17+
test.describe.configure({ mode: 'serial' });
18+
19+
let fakeUser: FakeUser;
20+
21+
test.beforeAll(async () => {
22+
const u = createTestUtils({ app });
23+
fakeUser = u.services.users.createFakeUser({
24+
withPhoneNumber: true,
25+
withUsername: true,
26+
});
27+
await u.services.users.createBapiUser(fakeUser);
28+
});
29+
30+
test.afterAll(async () => {
31+
await fakeUser.deleteIfExists();
32+
await app.teardown();
33+
});
34+
35+
/**
36+
* Test Flow:
37+
* 1. Open two tabs with the same browser context (shared cookies)
38+
* 2. Sign in on tab1, which creates a session
39+
* 3. Reload tab2 to pick up the session from cookies
40+
* 4. Clear token cache on both tabs
41+
* 5. Fetch token on tab1 (triggers network request + broadcast)
42+
* 6. Fetch token on tab2 (should use broadcasted token, no network request)
43+
*
44+
* Expected Behavior:
45+
* - Both tabs receive identical tokens
46+
* - Only ONE network request is made (from tab1)
47+
* - Tab2 gets the token via BroadcastChannel, proving cross-tab cache sharing
48+
*/
49+
test('MemoryTokenCache multi-tab token sharing', async ({ context }) => {
50+
const page1 = await context.newPage();
51+
const page2 = await context.newPage();
52+
53+
await page1.goto(app.serverUrl);
54+
await page2.goto(app.serverUrl);
55+
56+
await page1.waitForFunction(() => (window as any).Clerk?.loaded);
57+
await page2.waitForFunction(() => (window as any).Clerk?.loaded);
58+
59+
const u1 = createTestUtils({ app, page: page1 });
60+
await u1.po.signIn.goTo();
61+
await u1.po.signIn.setIdentifier(fakeUser.email);
62+
await u1.po.signIn.continue();
63+
await u1.po.signIn.setPassword(fakeUser.password);
64+
await u1.po.signIn.continue();
65+
await u1.po.expect.toBeSignedIn();
66+
67+
// eslint-disable-next-line playwright/no-wait-for-timeout
68+
await page1.waitForTimeout(1000);
69+
70+
await page2.reload();
71+
await page2.waitForFunction(() => (window as any).Clerk?.loaded);
72+
73+
const u2 = createTestUtils({ app, page: page2 });
74+
await u2.po.expect.toBeSignedIn();
75+
76+
const page1SessionInfo = await page1.evaluate(() => {
77+
const clerk = (window as any).Clerk;
78+
return {
79+
sessionId: clerk?.session?.id,
80+
userId: clerk?.user?.id,
81+
};
82+
});
83+
84+
expect(page1SessionInfo.sessionId).toBeDefined();
85+
expect(page1SessionInfo.userId).toBeDefined();
86+
87+
await Promise.all([
88+
page1.evaluate(() => (window as any).Clerk.session?.clearCache()),
89+
page2.evaluate(() => (window as any).Clerk.session?.clearCache()),
90+
]);
91+
92+
// Track token fetch requests to verify only one network call happens
93+
const tokenRequests: string[] = [];
94+
await context.route('**/v1/client/sessions/*/tokens*', async route => {
95+
tokenRequests.push(route.request().url());
96+
await route.continue();
97+
});
98+
99+
const page1Token = await page1.evaluate(async () => {
100+
const clerk = (window as any).Clerk;
101+
return await clerk.session?.getToken({ skipCache: true });
102+
});
103+
104+
expect(page1Token).toBeTruthy();
105+
106+
// Wait for broadcast to propagate between tabs (broadcast is nearly instant, but we add buffer)
107+
// eslint-disable-next-line playwright/no-wait-for-timeout
108+
await page2.waitForTimeout(2000);
109+
110+
const page2Result = await page2.evaluate(async () => {
111+
const clerk = (window as any).Clerk;
112+
113+
const token = await clerk.session?.getToken();
114+
115+
return {
116+
sessionId: clerk?.session?.id,
117+
token,
118+
userId: clerk?.user?.id,
119+
};
120+
});
121+
122+
expect(page2Result.sessionId).toBe(page1SessionInfo.sessionId);
123+
expect(page2Result.userId).toBe(page1SessionInfo.userId);
124+
125+
// If BroadcastChannel worked, both tabs should have the EXACT same token
126+
expect(page2Result.token).toBe(page1Token);
127+
128+
// Verify only one token fetch happened (page1), proving page2 got it from BroadcastChannel
129+
expect(tokenRequests.length).toBe(1);
130+
});
131+
},
132+
);

0 commit comments

Comments
 (0)