From d00ec576203dec1482b8b8b670362e7758b7770a Mon Sep 17 00:00:00 2001 From: Sarah Soutoul Date: Mon, 23 Mar 2026 19:00:09 -0600 Subject: [PATCH 1/3] docs(repo): Fix clerk-docs links in Typedoc output (#8155) --- .typedoc/custom-plugin.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.typedoc/custom-plugin.mjs b/.typedoc/custom-plugin.mjs index 9632735a98e..d2f31f4270e 100644 --- a/.typedoc/custom-plugin.mjs +++ b/.typedoc/custom-plugin.mjs @@ -53,10 +53,10 @@ const LINK_REPLACEMENTS = [ ['signed-in-session-resource', '/docs/reference/objects/session'], ['sign-in-resource', '/docs/reference/objects/sign-in'], ['sign-in-future-resource', '/docs/reference/objects/sign-in-future'], - ['sign-in-errors', '/docs/reference/javascript/types/errors'], + ['sign-in-errors', '/docs/reference/types/errors'], ['sign-up-resource', '/docs/reference/objects/sign-up'], ['sign-up-future-resource', '/docs/reference/objects/sign-up-future'], - ['sign-up-errors', '/docs/reference/javascript/types/errors'], + ['sign-up-errors', '/docs/reference/types/errors'], ['user-resource', '/docs/reference/objects/user'], ['session-status-claim', '/docs/reference/types/session-status'], ['user-organization-invitation-resource', '/docs/reference/types/user-organization-invitation'], @@ -164,7 +164,7 @@ function getCatchAllReplacements() { { pattern: /(? - `[\`${type}\`](/docs/reference/javascript/types/errors)`, + `[\`${type}\`](/docs/reference/types/errors)`, }, { pattern: /(? Date: Tue, 24 Mar 2026 11:08:31 +0100 Subject: [PATCH 2/3] Add failing test for single-session multi-tab token refresh deduping --- .../single-session.test.ts | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/integration/tests/session-token-cache/single-session.test.ts b/integration/tests/session-token-cache/single-session.test.ts index 03b5bd24953..9ba126722fd 100644 --- a/integration/tests/session-token-cache/single-session.test.ts +++ b/integration/tests/session-token-cache/single-session.test.ts @@ -46,7 +46,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( * - 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 }) => { + test('multi-tab token sharing works when clearing the cache', async ({ context }) => { const page1 = await context.newPage(); const page2 = await context.newPage(); @@ -128,5 +128,75 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( // Verify only one token fetch happened (page1), proving page2 got it from BroadcastChannel expect(tokenRequests.length).toBe(1); }); + + /** + * Test Flow: + * 1. Open two tabs with the same browser context (shared cookies) + * 2. Sign in on tab1, reload tab2 to pick up the session + * 3. Both tabs hydrate their token cache with the session token + * 4. Start counting /tokens requests, then wait for the timers to fire + * 5. Assert only 1 /tokens request was made (not 2) + */ + test('multi-tab scheduled refreshes are deduped to a single request', async ({ context }) => { + test.setTimeout(90_000); + + 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(); + + // Both tabs are now signed in and have hydrated their token caches + // via Session constructor -> #hydrateCache, each with an independent + // onRefresh timer that fires at ~43s (TTL 60s - 15s leeway - 2s lead). + // Start counting /tokens requests from this point. + const refreshRequests: string[] = []; + await context.route('**/v1/client/sessions/*/tokens*', async route => { + refreshRequests.push(route.request().url()); + await route.continue(); + }); + + // Wait for proactive refresh timers to fire. + // Default token TTL is 60s; onRefresh fires at 60 - 15 - 2 = 43s from iat. + // We wait 50s to give comfortable buffer, this includes the broadcast delay. + // + // Uses page.evaluate instead of page.waitForTimeout to avoid + // the global actionTimeout (10s) silently capping the wait. + await page1.evaluate(() => new Promise(resolve => setTimeout(resolve, 50_000))); + + // Only one tab should have made a /tokens request; the other tab should have + // received the refreshed token via BroadcastChannel. + expect(refreshRequests.length).toBe(1); + + // Both tabs should still have valid tokens after the refresh cycle + const [page1Token, page2Token] = await Promise.all([ + page1.evaluate(() => (window as any).Clerk.session?.getToken()), + page2.evaluate(() => (window as any).Clerk.session?.getToken()), + ]); + + expect(page1Token).toBeTruthy(); + expect(page2Token).toBeTruthy(); + expect(page1Token).toBe(page2Token); + }); }, ); From 5daf6a8197a597f68ae6cf6863fc5b772502450b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 24 Mar 2026 13:33:10 +0100 Subject: [PATCH 3/3] Add multi-session multi-tab token refresh dedupe e2e-test --- .../session-token-cache/multi-session.test.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/integration/tests/session-token-cache/multi-session.test.ts b/integration/tests/session-token-cache/multi-session.test.ts index 2f05eab18c3..95eeeae27cd 100644 --- a/integration/tests/session-token-cache/multi-session.test.ts +++ b/integration/tests/session-token-cache/multi-session.test.ts @@ -226,5 +226,106 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( expect(tab1FinalInfo.userId).toBe(user1SessionInfo.userId); expect(tab1FinalInfo.activeSessionId).toBe(user1SessionInfo.sessionId); }); + + /** + * Test Flow: + * 1. Tab1: Sign in as user1 + * 2. Tab2: Inherits user1's session, then signs in as user2 (multi-session) + * 3. Tab1 has user1's active session; tab2 has user2's active session + * 4. Each tab's active session independently hydrates its token cache + * 5. Start counting /tokens requests, wait for both refresh timers to fire + * 6. Assert exactly 2 /tokens requests (one per session), with each session + * represented exactly once + * + * Expected Behavior: + * - Two different sessions produce two independent refresh requests + * - BroadcastChannel does NOT deduplicate across sessions (different tokenIds) + * - Each session refreshes exactly once + * + * Note that this test does not currently assert in which tab the updates happen, + * this might be something we want to add in the future, but currently it is not + * deterministic. + */ + test('multi-session scheduled refreshes produce one request per session', async ({ context }) => { + test.setTimeout(90_000); + + 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 user1SessionId = await page1.evaluate(() => (window as any).Clerk?.session?.id); + expect(user1SessionId).toBeDefined(); + + 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(); + + // Sign in as user2 on tab2, creating a second session + const signInResult = await page2.evaluate( + async ({ email, password }) => { + const clerk = (window as any).Clerk; + const signIn = await clerk.client.signIn.create({ identifier: email, password }); + await clerk.setActive({ session: signIn.createdSessionId }); + return { + sessionCount: clerk?.client?.sessions?.length || 0, + sessionId: clerk?.session?.id, + success: true, + }; + }, + { email: fakeUser2.email, password: fakeUser2.password }, + ); + + expect(signInResult.success).toBe(true); + expect(signInResult.sessionCount).toBe(2); + + const user2SessionId = signInResult.sessionId; + expect(user2SessionId).toBeDefined(); + expect(user2SessionId).not.toBe(user1SessionId); + + // Tab1 has user1's active session; tab2 has user2's active session. + // Start counting /tokens requests. + const refreshRequests: Array<{ sessionId: string; url: string }> = []; + await context.route('**/v1/client/sessions/*/tokens*', async route => { + const url = route.request().url(); + const match = url.match(/sessions\/([^/]+)\/tokens/); + refreshRequests.push({ sessionId: match?.[1] || 'unknown', url }); + await route.continue(); + }); + + // Wait for proactive refresh timers to fire. + // Default token TTL is 60s; onRefresh fires at 60 - 15 - 2 = 43s from iat. + // Uses page.evaluate to avoid the global actionTimeout (10s) capping the wait. + await page1.evaluate(() => new Promise(resolve => setTimeout(resolve, 50_000))); + + // Two different sessions should each produce exactly one refresh request. + // BroadcastChannel deduplication is per-tokenId, so different sessions refresh independently. + expect(refreshRequests.length).toBe(2); + + const refreshedSessionIds = new Set(refreshRequests.map(r => r.sessionId)); + expect(refreshedSessionIds.has(user1SessionId)).toBe(true); + expect(refreshedSessionIds.has(user2SessionId)).toBe(true); + + // Both tabs should still have valid tokens after the refresh cycle + const page1Token = await page1.evaluate(() => (window as any).Clerk.session?.getToken()); + const page2Token = await page2.evaluate(() => (window as any).Clerk.session?.getToken()); + + expect(page1Token).toBeTruthy(); + expect(page2Token).toBeTruthy(); + expect(page1Token).not.toBe(page2Token); + }); }, );