From d9aa5bb9cfeaf02456aea9fd8d8e76cba985dc7c Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 20 Oct 2025 13:53:52 -0500 Subject: [PATCH 1/9] fix(clerk-js): Detect if user is signed in already --- .../src/ui/components/SignIn/SignInStart.tsx | 12 ++ .../SignIn/__tests__/SignInStart.test.tsx | 116 ++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index 6b0dd0cc8a9..a2dcdf5ddfd 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -181,6 +181,18 @@ function SignInStartInternal(): JSX.Element { setShouldAutofocus(true); }; + /** + * Redirect to account switcher if user already has active sessions in multi-session mode + */ + useEffect(() => { + const hasActiveSessions = (clerk.client?.signedInSessions?.length ?? 0) > 0; + const isMultiSessionMode = !authConfig.singleSessionMode; + + if (hasActiveSessions && isMultiSessionMode) { + void navigate('choose'); + } + }, [clerk.client?.signedInSessions, authConfig.singleSessionMode, navigate]); + // switch to the phone input (if available) if a "+" is entered // (either by the browser or the user) // this does not work in chrome as it does not fire the change event and the value is diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx index 5d6aad7857f..d863af86ff2 100644 --- a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx @@ -590,4 +590,120 @@ describe('SignInStart', () => { ); }); }); + + describe('Active session redirect', () => { + describe('multi-session mode', () => { + it('redirects to /choose when user has active sessions', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withMultiSessionMode(); + f.withUser({ + email_addresses: ['user@clerk.com'], + }); + }); + + // Mock active sessions + fixtures.clerk.client.signedInSessions = [ + { + id: 'sess_123', + user: fixtures.clerk.user, + status: 'active', + } as any, + ]; + + render(, { wrapper }); + + await waitFor(() => { + expect(fixtures.router.navigate).toHaveBeenCalledWith('choose'); + }); + }); + + it('redirects to /choose when user has multiple active sessions', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withMultiSessionMode(); + f.withUser({ + email_addresses: ['user@clerk.com'], + }); + }); + + // Mock multiple active sessions + fixtures.clerk.client.signedInSessions = [ + { + id: 'sess_123', + user: fixtures.clerk.user, + status: 'active', + } as any, + { + id: 'sess_456', + user: { id: 'user_456' }, + status: 'active', + } as any, + ]; + + render(, { wrapper }); + + await waitFor(() => { + expect(fixtures.router.navigate).toHaveBeenCalledWith('choose'); + }); + }); + + it('does NOT redirect when user has no active sessions', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withMultiSessionMode(); + }); + + // No active sessions + fixtures.clerk.client.signedInSessions = []; + + render(, { wrapper }); + + // Wait a bit to ensure no redirect happens + await waitFor( + () => { + expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); + }, + { timeout: 100 }, + ); + + // Should show the sign-in form + screen.getByText(/email address/i); + }); + }); + + describe('single-session mode', () => { + it('does NOT redirect to /choose when user has active session in single-session mode', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + // Single session mode is the default, no need to call a method + f.withUser({ + email_addresses: ['user@clerk.com'], + }); + }); + + // Mock active session in single-session mode + fixtures.clerk.client.signedInSessions = [ + { + id: 'sess_123', + user: fixtures.clerk.user, + status: 'active', + } as any, + ]; + + // Single-session mode + fixtures.environment.authConfig.singleSessionMode = true; + + render(, { wrapper }); + + // Should NOT redirect to choose in single-session mode + await waitFor( + () => { + expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); + }, + { timeout: 100 }, + ); + }); + }); + }); }); From a546210e31ffb23e8690b4a4eafd9ff2a3682c41 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 20 Oct 2025 13:58:52 -0500 Subject: [PATCH 2/9] remove extranous comments --- .../ui/components/SignIn/__tests__/SignInStart.test.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx index d863af86ff2..e95557d7b2c 100644 --- a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx @@ -602,7 +602,6 @@ describe('SignInStart', () => { }); }); - // Mock active sessions fixtures.clerk.client.signedInSessions = [ { id: 'sess_123', @@ -627,7 +626,6 @@ describe('SignInStart', () => { }); }); - // Mock multiple active sessions fixtures.clerk.client.signedInSessions = [ { id: 'sess_123', @@ -654,12 +652,10 @@ describe('SignInStart', () => { f.withMultiSessionMode(); }); - // No active sessions fixtures.clerk.client.signedInSessions = []; render(, { wrapper }); - // Wait a bit to ensure no redirect happens await waitFor( () => { expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); @@ -667,7 +663,6 @@ describe('SignInStart', () => { { timeout: 100 }, ); - // Should show the sign-in form screen.getByText(/email address/i); }); }); @@ -676,13 +671,11 @@ describe('SignInStart', () => { it('does NOT redirect to /choose when user has active session in single-session mode', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withEmailAddress(); - // Single session mode is the default, no need to call a method f.withUser({ email_addresses: ['user@clerk.com'], }); }); - // Mock active session in single-session mode fixtures.clerk.client.signedInSessions = [ { id: 'sess_123', @@ -691,12 +684,10 @@ describe('SignInStart', () => { } as any, ]; - // Single-session mode fixtures.environment.authConfig.singleSessionMode = true; render(, { wrapper }); - // Should NOT redirect to choose in single-session mode await waitFor( () => { expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); From 291e79fac8cc9e03300c4400930db9286a977a1c Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 20 Oct 2025 14:03:39 -0500 Subject: [PATCH 3/9] changeset --- .changeset/kind-paws-swim.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/kind-paws-swim.md diff --git a/.changeset/kind-paws-swim.md b/.changeset/kind-paws-swim.md new file mode 100644 index 00000000000..95025976244 --- /dev/null +++ b/.changeset/kind-paws-swim.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Detect when a user already has an active session in multi-session app and redirect to /choose subroute From cdbd2d8ca09b0731ea56f27ac5650b39e12f9f80 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 20 Oct 2025 14:25:56 -0500 Subject: [PATCH 4/9] fix test setup --- .../SignIn/__tests__/SignInStart.test.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx index e95557d7b2c..c694d1321b2 100644 --- a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx @@ -602,13 +602,14 @@ describe('SignInStart', () => { }); }); - fixtures.clerk.client.signedInSessions = [ + // Mock active sessions using spyOn + vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([ { id: 'sess_123', user: fixtures.clerk.user, status: 'active', } as any, - ]; + ]); render(, { wrapper }); @@ -626,7 +627,8 @@ describe('SignInStart', () => { }); }); - fixtures.clerk.client.signedInSessions = [ + // Mock multiple active sessions using spyOn + vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([ { id: 'sess_123', user: fixtures.clerk.user, @@ -637,7 +639,7 @@ describe('SignInStart', () => { user: { id: 'user_456' }, status: 'active', } as any, - ]; + ]); render(, { wrapper }); @@ -652,7 +654,8 @@ describe('SignInStart', () => { f.withMultiSessionMode(); }); - fixtures.clerk.client.signedInSessions = []; + // No active sessions using spyOn + vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([]); render(, { wrapper }); @@ -676,13 +679,14 @@ describe('SignInStart', () => { }); }); - fixtures.clerk.client.signedInSessions = [ + // Mock active session in single-session mode using spyOn + vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([ { id: 'sess_123', user: fixtures.clerk.user, status: 'active', } as any, - ]; + ]); fixtures.environment.authConfig.singleSessionMode = true; From 71d2e6af8596b0f4a4c6d94267a68ac5c181191c Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 20 Oct 2025 14:53:30 -0500 Subject: [PATCH 5/9] integration tests --- .../sign-in-active-session-redirect.test.ts | 74 ++++++++++++++++++ .../tests/sign-in-single-session-mode.test.ts | 75 +++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 integration/tests/sign-in-active-session-redirect.test.ts create mode 100644 integration/tests/sign-in-single-session-mode.test.ts diff --git a/integration/tests/sign-in-active-session-redirect.test.ts b/integration/tests/sign-in-active-session-redirect.test.ts new file mode 100644 index 00000000000..847858edbd9 --- /dev/null +++ b/integration/tests/sign-in-active-session-redirect.test.ts @@ -0,0 +1,74 @@ +import { test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import type { FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( + 'sign in redirect with active session @generic @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({ + withPhoneNumber: true, + withUsername: true, + }); + fakeUser2 = u.services.users.createFakeUser({ + withPhoneNumber: true, + withUsername: true, + }); + await u.services.users.createBapiUser(fakeUser1); + await u.services.users.createBapiUser(fakeUser2); + }); + + test.afterAll(async () => { + await fakeUser1.deleteIfExists(); + await fakeUser2.deleteIfExists(); + }); + + test('redirects to /sign-in/choose when visiting /sign-in with active session in multi-session mode', async ({ + page, + context, + }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser1.email, password: fakeUser1.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToAppHome(); + await u.po.signIn.goTo(); + await u.page.waitForURL(/sign-in\/choose/); + await u.page.waitForSelector('text=Choose an account'); + }); + + test('shows active session in account switcher with option to add account', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser1.email, password: fakeUser1.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.signIn.goTo(); + await u.page.waitForURL(/sign-in\/choose/); + await u.po.signIn.waitForMounted(); + await u.page.getByText('Add account').waitFor({ state: 'visible' }); + await u.page.getByText('Choose an account').waitFor({ state: 'visible' }); + }); + + test('shows sign-in form when no active sessions exist', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.page.context().clearCookies(); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.page.waitForSelector('text=/email address|username|phone/i'); + await u.page.waitForURL(/sign-in$/); + }); + }, +); diff --git a/integration/tests/sign-in-single-session-mode.test.ts b/integration/tests/sign-in-single-session-mode.test.ts new file mode 100644 index 00000000000..afd7a2c99fb --- /dev/null +++ b/integration/tests/sign-in-single-session-mode.test.ts @@ -0,0 +1,75 @@ +import { test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import type { FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( + 'sign in with active session in single-session mode @generic @nextjs', + ({ 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(); + }); + + test('redirects to afterSignIn URL when visiting /sign-in with active session in single-session mode', async ({ + page, + context, + }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + + await u.po.signIn.goTo(); + await u.page.waitForAppUrl('/'); + await u.po.expect.toBeSignedIn(); + }); + + test('does NOT show account switcher in single-session mode', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToRelative('/sign-in/choose'); + await u.page.waitForAppUrl('/'); + }); + + test('shows sign-in form when no active session in single-session mode', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.page.context().clearCookies(); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.page.waitForSelector('text=/email address|username|phone/i'); + await u.page.waitForURL(/sign-in$/); + }); + + test('can sign in normally when not already authenticated in single-session mode', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.page.context().clearCookies(); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + await u.page.signOut(); + }); + }, +); From 670d98c58d8817cd75935ddd844c8ddad0aa3348 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 20 Oct 2025 15:04:27 -0500 Subject: [PATCH 6/9] PR feedback --- .../sign-in-active-session-redirect.test.ts | 19 ++++++------------- .../src/ui/components/SignIn/SignInStart.tsx | 6 +++++- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/integration/tests/sign-in-active-session-redirect.test.ts b/integration/tests/sign-in-active-session-redirect.test.ts index 847858edbd9..c7a4f413e62 100644 --- a/integration/tests/sign-in-active-session-redirect.test.ts +++ b/integration/tests/sign-in-active-session-redirect.test.ts @@ -9,26 +9,19 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( ({ app }) => { test.describe.configure({ mode: 'serial' }); - let fakeUser1: FakeUser; - let fakeUser2: FakeUser; + let fakeUser: FakeUser; test.beforeAll(async () => { const u = createTestUtils({ app }); - fakeUser1 = u.services.users.createFakeUser({ + fakeUser = u.services.users.createFakeUser({ withPhoneNumber: true, withUsername: true, }); - fakeUser2 = u.services.users.createFakeUser({ - withPhoneNumber: true, - withUsername: true, - }); - await u.services.users.createBapiUser(fakeUser1); - await u.services.users.createBapiUser(fakeUser2); + await u.services.users.createBapiUser(fakeUser); }); test.afterAll(async () => { - await fakeUser1.deleteIfExists(); - await fakeUser2.deleteIfExists(); + await fakeUser.deleteIfExists(); }); test('redirects to /sign-in/choose when visiting /sign-in with active session in multi-session mode', async ({ @@ -38,7 +31,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser1.email, password: fakeUser1.password }); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); await u.po.expect.toBeSignedIn(); await u.page.goToAppHome(); @@ -51,7 +44,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser1.email, password: fakeUser1.password }); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); await u.po.expect.toBeSignedIn(); await u.po.signIn.goTo(); diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index a2dcdf5ddfd..f3e134f6ffe 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -185,13 +185,17 @@ function SignInStartInternal(): JSX.Element { * Redirect to account switcher if user already has active sessions in multi-session mode */ useEffect(() => { + if (organizationTicket) { + return; + } + const hasActiveSessions = (clerk.client?.signedInSessions?.length ?? 0) > 0; const isMultiSessionMode = !authConfig.singleSessionMode; if (hasActiveSessions && isMultiSessionMode) { void navigate('choose'); } - }, [clerk.client?.signedInSessions, authConfig.singleSessionMode, navigate]); + }, [clerk.client?.signedInSessions, authConfig.singleSessionMode, navigate, organizationTicket]); // switch to the phone input (if available) if a "+" is entered // (either by the browser or the user) From a027d82bf09c0285ffd9673b452929eca646fee1 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 20 Oct 2025 19:37:48 -0500 Subject: [PATCH 7/9] ensure add account works --- .../sign-in-active-session-redirect.test.ts | 28 +++++++++++++++++++ .../tests/sign-in-single-session-mode.test.ts | 10 +++++-- .../src/ui/components/SignIn/SignInStart.tsx | 11 ++++++++ .../SignIn/__tests__/SignInStart.test.tsx | 1 - .../UserButton/useMultisessionActions.tsx | 4 ++- 5 files changed, 49 insertions(+), 5 deletions(-) diff --git a/integration/tests/sign-in-active-session-redirect.test.ts b/integration/tests/sign-in-active-session-redirect.test.ts index c7a4f413e62..ea3c2682fa2 100644 --- a/integration/tests/sign-in-active-session-redirect.test.ts +++ b/integration/tests/sign-in-active-session-redirect.test.ts @@ -63,5 +63,33 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( await u.page.waitForSelector('text=/email address|username|phone/i'); await u.page.waitForURL(/sign-in$/); }); + + test('clicking "Add account" from /choose shows sign-in form without redirecting back', async ({ + page, + context, + }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.signIn.goTo(); + await u.page.waitForURL(/sign-in\/choose/); + + await u.page.getByText('Add account').click(); + await u.page.waitForURL(/sign-in$/); + await u.po.signIn.waitForMounted(); + await u.page.waitForSelector('text=/email address|username|phone/i'); + + await u.page.waitForTimeout(500); + const currentUrl = u.page.url(); + await u.page.waitForTimeout(500); + const urlAfterWait = u.page.url(); + + if (currentUrl !== urlAfterWait) { + throw new Error(`URL changed from ${currentUrl} to ${urlAfterWait}, indicating a redirect loop`); + } + }); }, ); diff --git a/integration/tests/sign-in-single-session-mode.test.ts b/integration/tests/sign-in-single-session-mode.test.ts index afd7a2c99fb..821027d021d 100644 --- a/integration/tests/sign-in-single-session-mode.test.ts +++ b/integration/tests/sign-in-single-session-mode.test.ts @@ -1,10 +1,15 @@ +import type { FakeUser } from '@clerk/testing/playwright'; import { test } from '@playwright/test'; +import type { Application } from '../models/application'; import { appConfigs } from '../presets'; -import type { FakeUser } from '../testUtils'; import { createTestUtils, testAgainstRunningApps } from '../testUtils'; -testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( +/** + * Tests for single-session mode behavior using the withBilling environment + * which is configured for single-session mode in the Clerk Dashboard. + */ +testAgainstRunningApps({ withEnv: [appConfigs.envs.withBilling] })( 'sign in with active session in single-session mode @generic @nextjs', ({ app }) => { test.describe.configure({ mode: 'serial' }); @@ -69,7 +74,6 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); await u.po.expect.toBeSignedIn(); await u.page.waitForAppUrl('/'); - await u.page.signOut(); }); }, ); diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index f3e134f6ffe..fd1c6ce58f3 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -189,6 +189,17 @@ function SignInStartInternal(): JSX.Element { return; } + const urlParams = new URLSearchParams(window.location.search); + const isAddingAccount = urlParams.has('__clerk_add_account'); + + if (isAddingAccount) { + urlParams.delete('__clerk_add_account'); + const newSearch = urlParams.toString(); + const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : ''); + window.history.replaceState({}, '', newUrl); + return; + } + const hasActiveSessions = (clerk.client?.signedInSessions?.length ?? 0) > 0; const isMultiSessionMode = !authConfig.singleSessionMode; diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx index c694d1321b2..dc438b5f1d9 100644 --- a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx @@ -679,7 +679,6 @@ describe('SignInStart', () => { }); }); - // Mock active session in single-session mode using spyOn vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([ { id: 'sess_123', diff --git a/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx b/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx index dd3d37e9c1f..af63b10f6cd 100644 --- a/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx @@ -99,7 +99,9 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { }; const handleAddAccountClicked = () => { - windowNavigate(opts.signInUrl || window.location.href); + const url = new URL(opts.signInUrl || window.location.href, window.location.origin); + url.searchParams.set('__clerk_add_account', '1'); + windowNavigate(url.toString()); return sleep(2000); }; From e4e793f2fdf032cf5e6fd9d27862e804bf52f853 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 21 Oct 2025 08:21:47 -0500 Subject: [PATCH 8/9] capture query param action in useRef --- .../sign-in-active-session-redirect.test.ts | 25 +++++++++---------- .../src/ui/components/SignIn/SignInStart.tsx | 6 +++++ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/integration/tests/sign-in-active-session-redirect.test.ts b/integration/tests/sign-in-active-session-redirect.test.ts index ea3c2682fa2..f65a7853a56 100644 --- a/integration/tests/sign-in-active-session-redirect.test.ts +++ b/integration/tests/sign-in-active-session-redirect.test.ts @@ -10,6 +10,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( test.describe.configure({ mode: 'serial' }); let fakeUser: FakeUser; + let fakeUser2: FakeUser; test.beforeAll(async () => { const u = createTestUtils({ app }); @@ -18,10 +19,17 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( withUsername: true, }); await u.services.users.createBapiUser(fakeUser); + + fakeUser2 = u.services.users.createFakeUser({ + withPhoneNumber: true, + withUsername: true, + }); + await u.services.users.createBapiUser(fakeUser2); }); test.afterAll(async () => { await fakeUser.deleteIfExists(); + await fakeUser2.deleteIfExists(); }); test('redirects to /sign-in/choose when visiting /sign-in with active session in multi-session mode', async ({ @@ -64,10 +72,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( await u.page.waitForURL(/sign-in$/); }); - test('clicking "Add account" from /choose shows sign-in form without redirecting back', async ({ - page, - context, - }) => { + test('can sign in to second account after clicking "Add account" from /choose', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); @@ -76,20 +81,14 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( await u.po.signIn.goTo(); await u.page.waitForURL(/sign-in\/choose/); + await u.po.signIn.waitForMounted(); await u.page.getByText('Add account').click(); await u.page.waitForURL(/sign-in$/); await u.po.signIn.waitForMounted(); - await u.page.waitForSelector('text=/email address|username|phone/i'); - await u.page.waitForTimeout(500); - const currentUrl = u.page.url(); - await u.page.waitForTimeout(500); - const urlAfterWait = u.page.url(); - - if (currentUrl !== urlAfterWait) { - throw new Error(`URL changed from ${currentUrl} to ${urlAfterWait}, indicating a redirect loop`); - } + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser2.email, password: fakeUser2.password }); + await u.po.expect.toBeSignedIn(); }); }, ); diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index fd1c6ce58f3..207098f69ab 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -109,6 +109,7 @@ function SignInStartInternal(): JSX.Element { shouldStartWithPhoneNumberIdentifier ? 'phone_number' : identifierAttributes[0] || '', ); const [hasSwitchedByAutofill, setHasSwitchedByAutofill] = useState(false); + const isAddingAccountRef = useRef(false); const organizationTicket = getClerkQueryParam('__clerk_ticket') || ''; const clerkStatus = getClerkQueryParam('__clerk_status') || ''; @@ -193,6 +194,7 @@ function SignInStartInternal(): JSX.Element { const isAddingAccount = urlParams.has('__clerk_add_account'); if (isAddingAccount) { + isAddingAccountRef.current = true; urlParams.delete('__clerk_add_account'); const newSearch = urlParams.toString(); const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : ''); @@ -200,6 +202,10 @@ function SignInStartInternal(): JSX.Element { return; } + if (isAddingAccountRef.current) { + return; + } + const hasActiveSessions = (clerk.client?.signedInSessions?.length ?? 0) > 0; const isMultiSessionMode = !authConfig.singleSessionMode; From 8290ce5ff03ff1c912e66893c3f406e3511e232d Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 21 Oct 2025 10:11:29 -0500 Subject: [PATCH 9/9] additional tweaks --- integration/tests/session-tasks-multi-session.test.ts | 10 ++++++++++ .../clerk-js/src/ui/components/SignIn/SignInStart.tsx | 11 ++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/integration/tests/session-tasks-multi-session.test.ts b/integration/tests/session-tasks-multi-session.test.ts index 7e923f6f957..3235d99fec1 100644 --- a/integration/tests/session-tasks-multi-session.test.ts +++ b/integration/tests/session-tasks-multi-session.test.ts @@ -61,6 +61,11 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( // Create second user, to initiate a pending session // Don't resolve task and switch to active session afterwards await u.po.signIn.goTo(); + await u.page.waitForURL(/sign-in\/choose/); + await u.page.getByText('Add account').click(); + await u.page.waitForURL(/sign-in$/); + await u.po.signIn.waitForMounted(); + await u.po.signIn.getIdentifierInput().waitFor({ state: 'visible' }); await u.po.signIn.setIdentifier(user2.email); await u.po.signIn.continue(); await u.po.signIn.setPassword(user2.password); @@ -68,6 +73,11 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( // Sign-in again back with active session await u.po.signIn.goTo(); + await u.page.waitForURL(/sign-in\/choose/); + await u.page.getByText('Add account').click(); + await u.page.waitForURL(/sign-in$/); + await u.po.signIn.waitForMounted(); + await u.po.signIn.getIdentifierInput().waitFor({ state: 'visible' }); await u.po.signIn.setIdentifier(user1.email); await u.po.signIn.continue(); await u.po.signIn.setPassword(user1.password); diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index 207098f69ab..69f574829b1 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -109,7 +109,7 @@ function SignInStartInternal(): JSX.Element { shouldStartWithPhoneNumberIdentifier ? 'phone_number' : identifierAttributes[0] || '', ); const [hasSwitchedByAutofill, setHasSwitchedByAutofill] = useState(false); - const isAddingAccountRef = useRef(false); + const hasInitializedRef = useRef(false); const organizationTicket = getClerkQueryParam('__clerk_ticket') || ''; const clerkStatus = getClerkQueryParam('__clerk_status') || ''; @@ -186,15 +186,16 @@ function SignInStartInternal(): JSX.Element { * Redirect to account switcher if user already has active sessions in multi-session mode */ useEffect(() => { - if (organizationTicket) { + if (organizationTicket || hasInitializedRef.current) { return; } + hasInitializedRef.current = true; + const urlParams = new URLSearchParams(window.location.search); const isAddingAccount = urlParams.has('__clerk_add_account'); if (isAddingAccount) { - isAddingAccountRef.current = true; urlParams.delete('__clerk_add_account'); const newSearch = urlParams.toString(); const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : ''); @@ -202,10 +203,6 @@ function SignInStartInternal(): JSX.Element { return; } - if (isAddingAccountRef.current) { - return; - } - const hasActiveSessions = (clerk.client?.signedInSessions?.length ?? 0) > 0; const isMultiSessionMode = !authConfig.singleSessionMode;