From 39e732a543f916e974e7401588b19408641167b7 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 26 Aug 2026 14:35:00 -0400 Subject: [PATCH] feat(ui): show the account switcher when signed-in sessions exist On multi-session instances, the sign-in start screen redirects to the existing choose route instead of rendering the identifier form when the client already has signed-in sessions. Flows that route through sign-in (e.g. OAuth authorization) continue with an existing account instead of asking for the email again. Add-account navigations bypass the switcher via __clerk_add_account, which the switcher's own action now sets while preserving redirect_url. Co-Authored-By: Claude Fable 5 --- .../signin-account-switcher-redirect.md | 6 +++ .../shared/src/internal/clerk-js/constants.ts | 6 +++ .../ui/src/components/SignIn/SignInStart.tsx | 41 ++++++++++++++++++- .../__tests__/SignInAccountSwitcher.test.tsx | 15 ++++--- .../SignIn/__tests__/SignInStart.test.tsx | 37 +++++++++++++++++ .../UserButton/useMultisessionActions.tsx | 13 +++++- 6 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 .changeset/signin-account-switcher-redirect.md diff --git a/.changeset/signin-account-switcher-redirect.md b/.changeset/signin-account-switcher-redirect.md new file mode 100644 index 00000000000..4a8d3a279a9 --- /dev/null +++ b/.changeset/signin-account-switcher-redirect.md @@ -0,0 +1,6 @@ +--- +'@clerk/ui': minor +'@clerk/shared': patch +--- + +On multi-session instances, visiting the sign-in start screen while accounts are already signed in now shows the account switcher instead of the identifier form, so flows arriving at sign-in (such as OAuth authorization) continue with an existing account instead of asking for the email again. Navigations that intend to add another account bypass the switcher via the `__clerk_add_account` search param — the switcher's "Add account" action sets it automatically and now also preserves `redirect_url`, so the newly added account continues where the flow left off. diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts index c11db68f590..c49e0c30f28 100644 --- a/packages/shared/src/internal/clerk-js/constants.ts +++ b/packages/shared/src/internal/clerk-js/constants.ts @@ -11,6 +11,12 @@ export const PRESERVED_QUERYSTRING_PARAMS = [ 'sign_up_fallback_redirect_url', ]; +/** + * Search param set when navigating to the sign-in start page to add another + * account. Bypasses the redirect to the account switcher that otherwise fires + * when signed-in sessions already exist on the client. + */ +export const CLERK_ADD_ACCOUNT = '__clerk_add_account'; export const CLERK_MODAL_STATE = '__clerk_modal_state'; export const CLERK_SYNCED = '__clerk_synced'; export const CLERK_SYNCED_STATUS = { diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 658643d83bb..0c6c2ef9252 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -1,5 +1,5 @@ import { getAlternativePhoneCodeProviderData } from '@clerk/shared/alternativePhoneCode'; -import { ERROR_CODES, SIGN_UP_MODES } from '@clerk/shared/internal/clerk-js/constants'; +import { CLERK_ADD_ACCOUNT, ERROR_CODES, SIGN_UP_MODES } from '@clerk/shared/internal/clerk-js/constants'; import { clerkInvalidFAPIResponse } from '@clerk/shared/internal/clerk-js/errors'; import { getClerkQueryParam, removeClerkQueryParam } from '@clerk/shared/internal/clerk-js/queryParams'; import { useClerk } from '@clerk/shared/react'; @@ -11,6 +11,7 @@ import type { SignInResource, } from '@clerk/shared/types'; import { isWebAuthnAutofillSupported, isWebAuthnSupported } from '@clerk/shared/webauthn'; +import type { ComponentType } from 'react'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Card } from '@/ui/elements/Card'; @@ -796,6 +797,42 @@ const InstantPasswordRow = ({ ); }; +/** + * On multi-session instances, a visit to the sign-in start screen with + * accounts already signed in renders the account switcher (the `choose` + * route) instead of the identifier form. Navigations that intend to add + * another account opt out via [CLERK_ADD_ACCOUNT]. + * + * Single-session instances are unaffected: `withRedirectToAfterSignIn` + * redirects their signed-in visitors before this guard runs. + */ +function withRedirectToAccountSwitcher

(Component: ComponentType

): ComponentType

{ + const HOC = (props: P) => { + const clerk = useClerk(); + const { authConfig } = useEnvironment(); + const { navigate, queryParams } = useRouter(); + + const shouldShowSwitcher = + !authConfig.singleSessionMode && + clerk.client.signedInSessions.length > 0 && + queryParams[CLERK_ADD_ACCOUNT] === undefined; + + useEffect(() => { + if (shouldShowSwitcher) { + void navigate('choose'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldShowSwitcher]); + + if (shouldShowSwitcher) { + return null; + } + return ; + }; + HOC.displayName = `withRedirectToAccountSwitcher(${Component.displayName || Component.name || 'Component'})`; + return HOC; +} + export const SignInStart = withRedirectToSignInTask( - withRedirectToAfterSignIn(withCardStateProvider(SignInStartInternal)), + withRedirectToAfterSignIn(withRedirectToAccountSwitcher(withCardStateProvider(SignInStartInternal))), ); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx index 54a8cd799de..e9c58a1451a 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx @@ -1,10 +1,13 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render } from '@/test/utils'; +import { clerkWindowNavigate } from '@/ui/utils/windowNavigate'; import { SignInAccountSwitcher } from '../SignInAccountSwitcher'; +vi.mock('@/ui/utils/windowNavigate', () => ({ clerkWindowNavigate: vi.fn() })); + const { createFixtures } = bindCreateFixtures('SignIn'); const initConfig = createFixtures.config(f => { @@ -36,12 +39,14 @@ describe('SignInAccountSwitcher', () => { expect(fixtures.clerk.setActive).toHaveBeenCalled(); }); - // this one uses the windowNavigate method. we need to mock it correctly - it.skip('navigates to SignInStart component if user clicks on "Add account" button', async () => { - const { wrapper, fixtures } = await createFixtures(initConfig); + it('navigates to sign-in with the add-account param when "Add account" is clicked', async () => { + const { wrapper } = await createFixtures(initConfig); const { userEvent, getByText } = render(, { wrapper }); await userEvent.click(getByText('Add account')); - expect(fixtures.router.navigate).toHaveBeenCalled(); + expect(clerkWindowNavigate).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('__clerk_add_account=true'), + ); }); it('signs out when user clicks on "Sign out of all accounts"', async () => { diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 36a0b24858b..a7286f11fdf 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -66,6 +66,43 @@ describe('SignInStart', () => { screen.getAllByText(/sign in to .*/i); }); + describe('account switcher redirect', () => { + it('redirects to the account switcher when signed-in sessions exist on a multi-session instance', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withMultiSessionMode(); + f.withUser({ email_addresses: ['test1@clerk.com'] }); + }); + render(, { wrapper }); + await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('choose')); + expect(screen.queryByText(/sign in to .*/i)).toBeNull(); + }); + + it('renders the identifier form when the add-account param is set', async () => { + const { createFixtures: createFixturesWithAddAccount } = bindCreateFixtures('SignIn', { + router: { queryParams: { __clerk_add_account: 'true' } }, + }); + const { wrapper, fixtures } = await createFixturesWithAddAccount(f => { + f.withEmailAddress(); + f.withMultiSessionMode(); + f.withUser({ email_addresses: ['test1@clerk.com'] }); + }); + render(, { wrapper }); + screen.getAllByText(/sign in to .*/i); + expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); + }); + + it('does not redirect when no signed-in sessions exist', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withMultiSessionMode(); + }); + render(, { wrapper }); + screen.getAllByText(/sign in to .*/i); + expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); + }); + }); + describe('Login Methods', () => { it('enables login with email address', async () => { const { wrapper } = await createFixtures(f => { diff --git a/packages/ui/src/components/UserButton/useMultisessionActions.tsx b/packages/ui/src/components/UserButton/useMultisessionActions.tsx index bb46235f382..e0cf3917589 100644 --- a/packages/ui/src/components/UserButton/useMultisessionActions.tsx +++ b/packages/ui/src/components/UserButton/useMultisessionActions.tsx @@ -1,3 +1,4 @@ +import { CLERK_ADD_ACCOUNT } from '@clerk/shared/internal/clerk-js/constants'; import { navigateIfTaskExists } from '@clerk/shared/internal/clerk-js/sessionTasks'; import { useClerk, usePortalRoot } from '@clerk/shared/react'; import type { SignedInSessionResource, UserButtonProps, UserResource } from '@clerk/shared/types'; @@ -102,7 +103,17 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { }; const handleAddAccountClicked = () => { - clerkWindowNavigate(clerk, opts.signInUrl || window.location.href); + const url = new URL(opts.signInUrl || window.location.href, window.location.origin); + // Keep an in-flight destination (e.g. an OAuth consent screen) so the + // newly added account continues where the flow left off. + const redirectUrl = new URLSearchParams(window.location.search).get('redirect_url'); + if (redirectUrl && !url.searchParams.has('redirect_url')) { + url.searchParams.set('redirect_url', redirectUrl); + } + // The sign-in start screen redirects to the account switcher when + // signed-in sessions exist; this param tells it to show the form instead. + url.searchParams.set(CLERK_ADD_ACCOUNT, 'true'); + clerkWindowNavigate(clerk, url.toString()); return sleep(2000); };