-
Notifications
You must be signed in to change notification settings - Fork 391
feat(clerk-js): Dedupe getToken requests #6891
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jacekradko
wants to merge
11
commits into
main
Choose a base branch
from
feat/multitab-poller
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
60a7e8f
feat(clerk-js): Dedupe getToken requests
jacekradko fbbd0c2
Merge branch 'main' into feat/multitab-poller
jacekradko 3139d59
duration based expiration check
jacekradko acf1e78
do not re-broadcast when handling message
jacekradko 38323d5
add new build variant
jacekradko 4f0c799
fix tests
jacekradko 5dcac2a
fix e2e and consolidate clerk-js index files
jacekradko be2b34b
this is not needed
jacekradko fce92f3
Merge branch 'main' into feat/multitab-poller
jacekradko 526153f
Merge branch 'main' into feat/multitab-poller
jacekradko 392cc40
Merge branch 'main' into feat/multitab-poller
jacekradko File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@clerk/clerk-js': minor | ||
--- | ||
|
||
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@clerk/shared': patch | ||
--- | ||
|
||
Introduce deprecation warning for LocalStorageBroadcastChannel |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
230 changes: 230 additions & 0 deletions
230
integration/tests/session-token-cache/multi-session.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,230 @@ | ||
import { expect, test } from '@playwright/test'; | ||
|
||
import { appConfigs } from '../../presets'; | ||
import type { FakeUser } from '../../testUtils'; | ||
import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; | ||
|
||
/** | ||
* Tests MemoryTokenCache session isolation in multi-session scenarios | ||
* | ||
* This suite validates that when multiple user sessions exist simultaneously, | ||
* each session maintains its own isolated token cache. Tokens are not shared | ||
* between different sessions, even within the same tab, ensuring proper | ||
* security boundaries between users. | ||
*/ | ||
testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( | ||
'MemoryTokenCache Multi-Session Integration @nextjs', | ||
({ app }) => { | ||
test.describe.configure({ mode: 'serial' }); | ||
|
||
let fakeUser1: FakeUser; | ||
let fakeUser2: FakeUser; | ||
|
||
test.beforeAll(async () => { | ||
const u = createTestUtils({ app }); | ||
fakeUser1 = u.services.users.createFakeUser(); | ||
fakeUser2 = u.services.users.createFakeUser(); | ||
await u.services.users.createBapiUser(fakeUser1); | ||
await u.services.users.createBapiUser(fakeUser2); | ||
}); | ||
|
||
test.afterAll(async () => { | ||
await fakeUser1.deleteIfExists(); | ||
await fakeUser2.deleteIfExists(); | ||
await app.teardown(); | ||
}); | ||
|
||
/** | ||
* Test Flow: | ||
* 1. Tab1: Sign in as user1, fetch and cache their token | ||
* 2. Tab2: Opens and inherits user1's session via cookies | ||
* 3. Tab2: Sign in as user2 using programmatic sign-in (preserves both sessions) | ||
* 4. Tab2: Now has two active sessions (user1 and user2) | ||
* 5. Tab2: Switch between sessions and fetch tokens for each | ||
* 6. Verify no network requests occur (tokens served from cache) | ||
* 7. Tab1: Verify it still has user1 as active session (tab independence) | ||
* | ||
* Expected Behavior: | ||
* - Each session has its own isolated token cache | ||
* - Switching sessions in tab2 returns different tokens | ||
* - Both tokens are served from cache (no network requests) | ||
* - Tab1 remains unaffected by tab2's session changes | ||
* - Multi-session state is properly maintained per-tab | ||
*/ | ||
test('MemoryTokenCache multi-session - multiple users in different tabs with separate token caches', async ({ | ||
context, | ||
}) => { | ||
const page1 = await context.newPage(); | ||
await page1.goto(app.serverUrl); | ||
await page1.waitForFunction(() => (window as any).Clerk?.loaded); | ||
|
||
const u1 = createTestUtils({ app, page: page1 }); | ||
await u1.po.signIn.goTo(); | ||
await u1.po.signIn.setIdentifier(fakeUser1.email); | ||
await u1.po.signIn.continue(); | ||
await u1.po.signIn.setPassword(fakeUser1.password); | ||
await u1.po.signIn.continue(); | ||
await u1.po.expect.toBeSignedIn(); | ||
|
||
const user1SessionInfo = await page1.evaluate(() => { | ||
const clerk = (window as any).Clerk; | ||
return { | ||
sessionId: clerk?.session?.id, | ||
userId: clerk?.user?.id, | ||
}; | ||
}); | ||
|
||
expect(user1SessionInfo.sessionId).toBeDefined(); | ||
expect(user1SessionInfo.userId).toBeDefined(); | ||
|
||
const user1Token = await page1.evaluate(async () => { | ||
const clerk = (window as any).Clerk; | ||
return await clerk.session?.getToken({ skipCache: true }); | ||
}); | ||
|
||
expect(user1Token).toBeTruthy(); | ||
|
||
const page2 = await context.newPage(); | ||
await page2.goto(app.serverUrl); | ||
await page2.waitForFunction(() => (window as any).Clerk?.loaded); | ||
|
||
// eslint-disable-next-line playwright/no-wait-for-timeout | ||
await page2.waitForTimeout(1000); | ||
|
||
const u2 = createTestUtils({ app, page: page2 }); | ||
await u2.po.expect.toBeSignedIn(); | ||
|
||
const page2User1SessionInfo = await page2.evaluate(() => { | ||
const clerk = (window as any).Clerk; | ||
return { | ||
sessionId: clerk?.session?.id, | ||
userId: clerk?.user?.id, | ||
}; | ||
}); | ||
|
||
expect(page2User1SessionInfo.userId).toBe(user1SessionInfo.userId); | ||
expect(page2User1SessionInfo.sessionId).toBe(user1SessionInfo.sessionId); | ||
|
||
// Use clerk.client.signIn.create() instead of navigating to /sign-in | ||
// because navigating replaces the session by default (transferable: true) | ||
const signInResult = await page2.evaluate( | ||
async ({ email, password }) => { | ||
const clerk = (window as any).Clerk; | ||
|
||
try { | ||
const signIn = await clerk.client.signIn.create({ | ||
identifier: email, | ||
password: password, | ||
}); | ||
|
||
await clerk.setActive({ | ||
session: signIn.createdSessionId, | ||
}); | ||
|
||
return { | ||
allSessions: clerk?.client?.sessions?.map((s: any) => ({ id: s.id, userId: s.userId })) || [], | ||
sessionCount: clerk?.client?.sessions?.length || 0, | ||
success: true, | ||
}; | ||
} catch (error: any) { | ||
return { | ||
error: error.message || String(error), | ||
success: false, | ||
}; | ||
} | ||
}, | ||
{ email: fakeUser2.email, password: fakeUser2.password }, | ||
); | ||
|
||
expect(signInResult.success).toBe(true); | ||
expect(signInResult.sessionCount).toBe(2); | ||
|
||
await u2.po.expect.toBeSignedIn(); | ||
|
||
const user2SessionInfo = await page2.evaluate(() => { | ||
const clerk = (window as any).Clerk; | ||
return { | ||
allSessions: clerk?.client?.sessions?.map((s: any) => ({ id: s.id, userId: s.userId })) || [], | ||
sessionCount: clerk?.client?.sessions?.length || 0, | ||
sessionId: clerk?.session?.id, | ||
userId: clerk?.user?.id, | ||
}; | ||
}); | ||
|
||
expect(user2SessionInfo.sessionId).toBeDefined(); | ||
expect(user2SessionInfo.userId).toBeDefined(); | ||
expect(user2SessionInfo.sessionId).not.toBe(user1SessionInfo.sessionId); | ||
expect(user2SessionInfo.userId).not.toBe(user1SessionInfo.userId); | ||
|
||
const user2Token = await page2.evaluate(async () => { | ||
const clerk = (window as any).Clerk; | ||
return await clerk.session?.getToken({ skipCache: true }); | ||
}); | ||
|
||
expect(user2Token).toBeTruthy(); | ||
expect(user2Token).not.toBe(user1Token); | ||
|
||
const page2MultiSessionInfo = await page2.evaluate(() => { | ||
const clerk = (window as any).Clerk; | ||
return { | ||
activeSessionId: clerk?.session?.id, | ||
allSessionIds: clerk?.client?.sessions?.map((s: any) => s.id) || [], | ||
sessionCount: clerk?.client?.sessions?.length || 0, | ||
}; | ||
}); | ||
|
||
expect(page2MultiSessionInfo.sessionCount).toBe(2); | ||
expect(page2MultiSessionInfo.allSessionIds).toContain(user1SessionInfo.sessionId); | ||
expect(page2MultiSessionInfo.allSessionIds).toContain(user2SessionInfo.sessionId); | ||
expect(page2MultiSessionInfo.activeSessionId).toBe(user2SessionInfo.sessionId); | ||
|
||
const tokenFetchRequests: Array<{ sessionId: string; url: string }> = []; | ||
await context.route('**/v1/client/sessions/*/tokens*', async route => { | ||
const url = route.request().url(); | ||
const sessionIdMatch = url.match(/sessions\/([^/]+)\/tokens/); | ||
const sessionId = sessionIdMatch?.[1] || 'unknown'; | ||
tokenFetchRequests.push({ sessionId, url }); | ||
await route.continue(); | ||
}); | ||
|
||
const tokenIsolation = await page2.evaluate( | ||
async ({ user1SessionId, user2SessionId }) => { | ||
const clerk = (window as any).Clerk; | ||
|
||
await clerk.setActive({ session: user1SessionId }); | ||
const user1Token = await clerk.session?.getToken(); | ||
|
||
await clerk.setActive({ session: user2SessionId }); | ||
const user2Token = await clerk.session?.getToken(); | ||
|
||
return { | ||
tokensAreDifferent: user1Token !== user2Token, | ||
user1Token, | ||
user2Token, | ||
}; | ||
}, | ||
{ user1SessionId: user1SessionInfo.sessionId, user2SessionId: user2SessionInfo.sessionId }, | ||
); | ||
|
||
expect(tokenIsolation.tokensAreDifferent).toBe(true); | ||
expect(tokenIsolation.user1Token).toBeTruthy(); | ||
expect(tokenIsolation.user2Token).toBeTruthy(); | ||
expect(tokenFetchRequests.length).toBe(0); | ||
|
||
await context.unroute('**/v1/client/sessions/*/tokens*'); | ||
|
||
// In multi-session apps, each tab can have a different active session | ||
const tab1FinalInfo = await page1.evaluate(() => { | ||
const clerk = (window as any).Clerk; | ||
return { | ||
activeSessionId: clerk?.session?.id, | ||
userId: clerk?.user?.id, | ||
}; | ||
}); | ||
|
||
// Tab1 should STILL have user1 as the active session (independent per tab) | ||
expect(tab1FinalInfo.userId).toBe(user1SessionInfo.userId); | ||
expect(tab1FinalInfo.activeSessionId).toBe(user1SessionInfo.sessionId); | ||
}); | ||
}, | ||
); |
132 changes: 132 additions & 0 deletions
132
integration/tests/session-token-cache/single-session.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
import { expect, test } from '@playwright/test'; | ||
|
||
import { appConfigs } from '../../presets'; | ||
import type { FakeUser } from '../../testUtils'; | ||
import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; | ||
|
||
/** | ||
* Tests MemoryTokenCache cross-tab token sharing via BroadcastChannel | ||
* | ||
* This suite validates that when multiple browser tabs share the same user session, | ||
* token fetches in one tab are automatically broadcast and cached in other tabs, | ||
* eliminating redundant network requests. | ||
*/ | ||
testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( | ||
'MemoryTokenCache Multi-Tab Integration @generic', | ||
({ app }) => { | ||
test.describe.configure({ mode: 'serial' }); | ||
|
||
let fakeUser: FakeUser; | ||
|
||
test.beforeAll(async () => { | ||
const u = createTestUtils({ app }); | ||
fakeUser = u.services.users.createFakeUser({ | ||
withPhoneNumber: true, | ||
withUsername: true, | ||
}); | ||
await u.services.users.createBapiUser(fakeUser); | ||
}); | ||
|
||
test.afterAll(async () => { | ||
await fakeUser.deleteIfExists(); | ||
await app.teardown(); | ||
}); | ||
|
||
/** | ||
* Test Flow: | ||
* 1. Open two tabs with the same browser context (shared cookies) | ||
* 2. Sign in on tab1, which creates a session | ||
* 3. Reload tab2 to pick up the session from cookies | ||
* 4. Clear token cache on both tabs | ||
* 5. Fetch token on tab1 (triggers network request + broadcast) | ||
* 6. Fetch token on tab2 (should use broadcasted token, no network request) | ||
* | ||
* Expected Behavior: | ||
* - Both tabs receive identical tokens | ||
* - Only ONE network request is made (from tab1) | ||
* - Tab2 gets the token via BroadcastChannel, proving cross-tab cache sharing | ||
*/ | ||
test('MemoryTokenCache multi-tab token sharing', async ({ context }) => { | ||
const page1 = await context.newPage(); | ||
const page2 = await context.newPage(); | ||
|
||
await page1.goto(app.serverUrl); | ||
await page2.goto(app.serverUrl); | ||
|
||
await page1.waitForFunction(() => (window as any).Clerk?.loaded); | ||
await page2.waitForFunction(() => (window as any).Clerk?.loaded); | ||
|
||
const u1 = createTestUtils({ app, page: page1 }); | ||
await u1.po.signIn.goTo(); | ||
await u1.po.signIn.setIdentifier(fakeUser.email); | ||
await u1.po.signIn.continue(); | ||
await u1.po.signIn.setPassword(fakeUser.password); | ||
await u1.po.signIn.continue(); | ||
await u1.po.expect.toBeSignedIn(); | ||
|
||
// eslint-disable-next-line playwright/no-wait-for-timeout | ||
await page1.waitForTimeout(1000); | ||
|
||
await page2.reload(); | ||
await page2.waitForFunction(() => (window as any).Clerk?.loaded); | ||
|
||
const u2 = createTestUtils({ app, page: page2 }); | ||
await u2.po.expect.toBeSignedIn(); | ||
|
||
const page1SessionInfo = await page1.evaluate(() => { | ||
const clerk = (window as any).Clerk; | ||
return { | ||
sessionId: clerk?.session?.id, | ||
userId: clerk?.user?.id, | ||
}; | ||
}); | ||
|
||
expect(page1SessionInfo.sessionId).toBeDefined(); | ||
expect(page1SessionInfo.userId).toBeDefined(); | ||
|
||
await Promise.all([ | ||
page1.evaluate(() => (window as any).Clerk.session?.clearCache()), | ||
page2.evaluate(() => (window as any).Clerk.session?.clearCache()), | ||
]); | ||
|
||
// Track token fetch requests to verify only one network call happens | ||
const tokenRequests: string[] = []; | ||
await context.route('**/v1/client/sessions/*/tokens*', async route => { | ||
tokenRequests.push(route.request().url()); | ||
await route.continue(); | ||
}); | ||
|
||
const page1Token = await page1.evaluate(async () => { | ||
const clerk = (window as any).Clerk; | ||
return await clerk.session?.getToken({ skipCache: true }); | ||
}); | ||
|
||
expect(page1Token).toBeTruthy(); | ||
|
||
// Wait for broadcast to propagate between tabs (broadcast is nearly instant, but we add buffer) | ||
// eslint-disable-next-line playwright/no-wait-for-timeout | ||
await page2.waitForTimeout(2000); | ||
|
||
const page2Result = await page2.evaluate(async () => { | ||
const clerk = (window as any).Clerk; | ||
|
||
const token = await clerk.session?.getToken(); | ||
|
||
return { | ||
sessionId: clerk?.session?.id, | ||
token, | ||
userId: clerk?.user?.id, | ||
}; | ||
}); | ||
|
||
expect(page2Result.sessionId).toBe(page1SessionInfo.sessionId); | ||
expect(page2Result.userId).toBe(page1SessionInfo.userId); | ||
|
||
// If BroadcastChannel worked, both tabs should have the EXACT same token | ||
expect(page2Result.token).toBe(page1Token); | ||
|
||
// Verify only one token fetch happened (page1), proving page2 got it from BroadcastChannel | ||
expect(tokenRequests.length).toBe(1); | ||
}); | ||
}, | ||
); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the key expectation, that only 1 token fetch requests happened between the 2 tabs