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: /(? { + 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); + }); }, ); 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); + }); }, );