From 4be59f8e5c932211e23f5ccbd3f5057faa24760e Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 3 Sep 2026 13:03:34 -0400 Subject: [PATCH 1/4] feat(ui): add experimental multiSessionStart prop to SignIn On multi-session instances, __experimental_multiSessionStart='switcher' starts a signed-in visitor on the existing account switcher (choose route) instead of the identifier form. Default 'form' keeps current behavior; ignored in single-session mode. The switcher's "Add account" action now keeps the current redirect_url and sets __clerk_add_account, which the start screen honors to render the form and the router preserves across internal navigations. Co-Authored-By: Claude Fable 5.1 --- .changeset/signin-multisession-start.md | 6 ++ .../shared/src/internal/clerk-js/constants.ts | 4 ++ packages/shared/src/types/clerk.ts | 10 ++++ .../SignIn/SignInAccountSwitcher.tsx | 11 +++- .../ui/src/components/SignIn/SignInStart.tsx | 33 ++++++++++- .../__tests__/SignInAccountSwitcher.test.tsx | 28 ++++++++-- .../SignIn/__tests__/SignInStart.test.tsx | 55 +++++++++++++++++++ .../src/router/__tests__/BaseRouter.test.tsx | 43 ++++++++++++++- 8 files changed, 181 insertions(+), 9 deletions(-) create mode 100644 .changeset/signin-multisession-start.md diff --git a/.changeset/signin-multisession-start.md b/.changeset/signin-multisession-start.md new file mode 100644 index 00000000000..884c0269968 --- /dev/null +++ b/.changeset/signin-multisession-start.md @@ -0,0 +1,6 @@ +--- +'@clerk/ui': minor +'@clerk/shared': minor +--- + +Add an experimental `__experimental_multiSessionStart` prop to ``. On multi-session instances, setting it to `'switcher'` starts a signed-in visitor on the account switcher (listing the signed-in accounts, with "Add account" and "Sign out of all accounts") instead of the identifier form, so flows that route through sign-in such as OAuth authorization can continue with an existing account. The default `'form'` keeps the current behavior, and the prop is ignored in single-session mode. The switcher's "Add account" action now also preserves the current `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..5ba7ebb8680 100644 --- a/packages/shared/src/internal/clerk-js/constants.ts +++ b/packages/shared/src/internal/clerk-js/constants.ts @@ -1,5 +1,8 @@ import type { SignUpModes } from '../../types'; +// Set on add-account navigations so the sign-in start screen renders the identifier form instead of the account switcher. +export const CLERK_ADD_ACCOUNT = '__clerk_add_account'; + // TODO: Do we still have a use for this or can we simply preserve all params? export const PRESERVED_QUERYSTRING_PARAMS = [ 'redirect_url', @@ -9,6 +12,7 @@ export const PRESERVED_QUERYSTRING_PARAMS = [ 'sign_in_fallback_redirect_url', 'sign_up_force_redirect_url', 'sign_up_fallback_redirect_url', + CLERK_ADD_ACCOUNT, ]; export const CLERK_MODAL_STATE = '__clerk_modal_state'; diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 49f7875870c..1f1f29f9e22 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1902,6 +1902,16 @@ export type SignInProps = RoutingOptions & { * Optional for `oauth_` or `enterprise_sso` strategies. The value to pass to the [OIDC prompt parameter](https://openid.net/specs/openid-connect-core-1_0.html#:~:text=prompt,reauthentication%20and%20consent.) in the generated OAuth redirect URL. */ oidcPrompt?: string; + /** + * On multi-session instances, where a signed-in visitor lands when opening the sign-in component. + * `'form'` renders the identifier form. `'switcher'` renders the account switcher listing the signed-in accounts, + * with "Add account" and "Sign out of all accounts". Ignored in single-session mode. + * + * @default 'form' + * + * @experimental + */ + __experimental_multiSessionStart?: 'form' | 'switcher'; } & TransferableOption & SignUpForceRedirectUrl & SignUpFallbackRedirectUrl & diff --git a/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx b/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx index 3540530b6c7..ec2f7a6cee1 100644 --- a/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx +++ b/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx @@ -1,3 +1,5 @@ +import { CLERK_ADD_ACCOUNT } from '@clerk/shared/internal/clerk-js/constants'; + import { Action, Actions } from '@/ui/elements/Actions'; import { Card } from '@/ui/elements/Card'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; @@ -9,6 +11,7 @@ import { withRedirectToAfterSignIn } from '../../common'; import { useEnvironment, useSignInContext, useSignOutContext } from '../../contexts'; import { Col, descriptors, Flow, localizationKeys } from '../../customizables'; import { Add, ArrowRight } from '../../icons'; +import { useRouter } from '../../router'; import { SignOutAllActions } from '../UserButton/SessionActions'; import { useMultisessionActions } from '../UserButton/useMultisessionActions'; @@ -17,13 +20,19 @@ const SignInAccountSwitcherInternal = () => { const { userProfileUrl } = useEnvironment().displayConfig; const { afterSignInUrl, path: signInPath, signInUrl, taskUrl } = useSignInContext(); const { navigateAfterSignOut } = useSignOutContext(); + const { queryParams } = useRouter(); + const addAccountUrl = new URL((signInPath ?? signInUrl) || window.location.href, window.location.origin); + if (queryParams.redirect_url && !addAccountUrl.searchParams.has('redirect_url')) { + addAccountUrl.searchParams.set('redirect_url', queryParams.redirect_url); + } + addAccountUrl.searchParams.set(CLERK_ADD_ACCOUNT, 'true'); const { handleSignOutAllClicked, handleSessionClicked, signedInSessions, handleAddAccountClicked } = useMultisessionActions({ taskUrl, navigateAfterSignOut, afterSwitchSessionUrl: afterSignInUrl, userProfileUrl, - signInUrl: signInPath ?? signInUrl, + signInUrl: addAccountUrl.toString(), user: undefined, }); diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index a95040b8465..a386d49b4b6 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'; @@ -797,6 +798,34 @@ const InstantPasswordRow = ({ ); }; +const withRedirectToAccountSwitcher =

(Component: ComponentType

) => { + const HOC = (props: P) => { + const clerk = useClerk(); + const { authConfig } = useEnvironment(); + const { __experimental_multiSessionStart } = useSignInContext(); + const { navigate, queryParams } = useRouter(); + + const shouldShowSwitcher = + __experimental_multiSessionStart === 'switcher' && + !authConfig.singleSessionMode && + clerk.client.signedInSessions.length > 0 && + queryParams[CLERK_ADD_ACCOUNT] === undefined; + + useEffect(() => { + if (shouldShowSwitcher) { + void navigate('choose'); + } + }, [shouldShowSwitcher, navigate]); + + 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..8c33105eac6 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,27 @@ 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(clerkWindowNavigate).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('__clerk_add_account=true'), + ); + }); + + it('keeps the current redirect_url when "Add account" is clicked', async () => { + const { createFixtures: createFixturesWithRedirect } = bindCreateFixtures('SignIn', { + router: { queryParams: { redirect_url: 'https://example.com/consent' } }, + }); + const { wrapper } = await createFixturesWithRedirect(initConfig); const { userEvent, getByText } = render(, { wrapper }); await userEvent.click(getByText('Add account')); - expect(fixtures.router.navigate).toHaveBeenCalled(); + expect(clerkWindowNavigate).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('redirect_url=https%3A%2F%2Fexample.com%2Fconsent'), + ); }); 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..3cb14cfc4b6 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -66,6 +66,61 @@ describe('SignInStart', () => { screen.getAllByText(/sign in to .*/i); }); + describe('multi-session start', () => { + const withSignedInSessions = (f: Parameters[0]>[0]) => { + f.withEmailAddress(); + f.withMultiSessionMode(); + f.withUser({ email_addresses: ['test1@clerk.com'] }); + }; + + it('renders the identifier form when the prop is unset and signed-in sessions exist', async () => { + const { wrapper, fixtures } = await createFixtures(withSignedInSessions); + render(, { wrapper }); + screen.getAllByText(/sign in to .*/i); + expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); + }); + + it('redirects to the account switcher when the prop is "switcher" and signed-in sessions exist', async () => { + const { wrapper, fixtures, props } = await createFixtures(withSignedInSessions); + props.setProps({ __experimental_multiSessionStart: 'switcher' }); + 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 prop is "switcher" and no signed-in sessions exist', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withMultiSessionMode(); + }); + props.setProps({ __experimental_multiSessionStart: 'switcher' }); + render(, { wrapper }); + screen.getAllByText(/sign in to .*/i); + expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); + }); + + 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, props } = await createFixturesWithAddAccount(withSignedInSessions); + props.setProps({ __experimental_multiSessionStart: 'switcher' }); + render(, { wrapper }); + screen.getAllByText(/sign in to .*/i); + expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); + }); + + it('does not redirect to the account switcher in single-session mode', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ email_addresses: ['test1@clerk.com'] }); + }); + props.setProps({ __experimental_multiSessionStart: 'switcher' }); + render(, { wrapper }); + 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/router/__tests__/BaseRouter.test.tsx b/packages/ui/src/router/__tests__/BaseRouter.test.tsx index 901ca2ea078..e48ff2c2b07 100644 --- a/packages/ui/src/router/__tests__/BaseRouter.test.tsx +++ b/packages/ui/src/router/__tests__/BaseRouter.test.tsx @@ -1,5 +1,6 @@ +import { PRESERVED_QUERYSTRING_PARAMS } from '@clerk/shared/internal/clerk-js/constants'; import type { Clerk } from '@clerk/shared/types'; -import { act, render, screen } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; @@ -195,4 +196,44 @@ describe('BaseRouter basePath guard', () => { expect(screen.getByTestId('factor-one')).toBeInTheDocument(); }); }); + + describe('preserved query params', () => { + it('carries __clerk_add_account across an internal navigation', async () => { + setWindowLocation('https://www.example.com/sign-in?__clerk_add_account=true'); + + const NavigateTrigger = () => { + const router = useRouter(); + return ( + + ); + }; + + render( + + +

Factor One
+ + + + + , + ); + + act(() => { + screen.getByTestId('go').click(); + }); + + await waitFor(() => expect(screen.getByTestId('factor-one')).toBeInTheDocument()); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('__clerk_add_account=true')); + }); + }); }); From 8c1f14fc8b190c94cb4faae5ba2d71969de1486a Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 3 Sep 2026 13:21:22 -0400 Subject: [PATCH 2/4] feat(ui): graduate multiSessionStart prop from experimental Co-Authored-By: Claude Fable 5.1 --- .changeset/signin-multisession-start.md | 2 +- packages/shared/src/types/clerk.ts | 4 +--- packages/ui/src/components/SignIn/SignInStart.tsx | 4 ++-- .../src/components/SignIn/__tests__/SignInStart.test.tsx | 8 ++++---- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.changeset/signin-multisession-start.md b/.changeset/signin-multisession-start.md index 884c0269968..271f3639c84 100644 --- a/.changeset/signin-multisession-start.md +++ b/.changeset/signin-multisession-start.md @@ -3,4 +3,4 @@ '@clerk/shared': minor --- -Add an experimental `__experimental_multiSessionStart` prop to ``. On multi-session instances, setting it to `'switcher'` starts a signed-in visitor on the account switcher (listing the signed-in accounts, with "Add account" and "Sign out of all accounts") instead of the identifier form, so flows that route through sign-in such as OAuth authorization can continue with an existing account. The default `'form'` keeps the current behavior, and the prop is ignored in single-session mode. The switcher's "Add account" action now also preserves the current `redirect_url`, so the newly added account continues where the flow left off. +Add a `multiSessionStart` prop to ``. On multi-session instances, setting it to `'switcher'` starts a signed-in visitor on the account switcher (listing the signed-in accounts, with "Add account" and "Sign out of all accounts") instead of the identifier form, so flows that route through sign-in such as OAuth authorization can continue with an existing account. The default `'form'` keeps the current behavior, and the prop is ignored in single-session mode. The switcher's "Add account" action now also preserves the current `redirect_url`, so the newly added account continues where the flow left off. diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 1f1f29f9e22..1e831ae1219 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1908,10 +1908,8 @@ export type SignInProps = RoutingOptions & { * with "Add account" and "Sign out of all accounts". Ignored in single-session mode. * * @default 'form' - * - * @experimental */ - __experimental_multiSessionStart?: 'form' | 'switcher'; + multiSessionStart?: 'form' | 'switcher'; } & TransferableOption & SignUpForceRedirectUrl & SignUpFallbackRedirectUrl & diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index a386d49b4b6..5d6e6f20c1c 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -802,11 +802,11 @@ const withRedirectToAccountSwitcher =

(Component: ComponentTyp const HOC = (props: P) => { const clerk = useClerk(); const { authConfig } = useEnvironment(); - const { __experimental_multiSessionStart } = useSignInContext(); + const { multiSessionStart } = useSignInContext(); const { navigate, queryParams } = useRouter(); const shouldShowSwitcher = - __experimental_multiSessionStart === 'switcher' && + multiSessionStart === 'switcher' && !authConfig.singleSessionMode && clerk.client.signedInSessions.length > 0 && queryParams[CLERK_ADD_ACCOUNT] === undefined; diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 3cb14cfc4b6..51bf03efee8 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -82,7 +82,7 @@ describe('SignInStart', () => { it('redirects to the account switcher when the prop is "switcher" and signed-in sessions exist', async () => { const { wrapper, fixtures, props } = await createFixtures(withSignedInSessions); - props.setProps({ __experimental_multiSessionStart: 'switcher' }); + props.setProps({ multiSessionStart: 'switcher' }); render(, { wrapper }); await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('choose')); expect(screen.queryByText(/sign in to .*/i)).toBeNull(); @@ -93,7 +93,7 @@ describe('SignInStart', () => { f.withEmailAddress(); f.withMultiSessionMode(); }); - props.setProps({ __experimental_multiSessionStart: 'switcher' }); + props.setProps({ multiSessionStart: 'switcher' }); render(, { wrapper }); screen.getAllByText(/sign in to .*/i); expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); @@ -104,7 +104,7 @@ describe('SignInStart', () => { router: { queryParams: { __clerk_add_account: 'true' } }, }); const { wrapper, fixtures, props } = await createFixturesWithAddAccount(withSignedInSessions); - props.setProps({ __experimental_multiSessionStart: 'switcher' }); + props.setProps({ multiSessionStart: 'switcher' }); render(, { wrapper }); screen.getAllByText(/sign in to .*/i); expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); @@ -115,7 +115,7 @@ describe('SignInStart', () => { f.withEmailAddress(); f.withUser({ email_addresses: ['test1@clerk.com'] }); }); - props.setProps({ __experimental_multiSessionStart: 'switcher' }); + props.setProps({ multiSessionStart: 'switcher' }); render(, { wrapper }); expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); }); From 8a317faca64d87b289a1f71333b0d6defbb3d399 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 3 Sep 2026 13:32:13 -0400 Subject: [PATCH 3/4] fix(ui): write add-account params to the fragment so hash routing sees them Co-Authored-By: Claude Fable 5.1 --- .../components/SignIn/SignInAccountSwitcher.tsx | 17 ++++++++--------- .../__tests__/SignInAccountSwitcher.test.tsx | 4 ++-- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx b/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx index ec2f7a6cee1..31e6953d602 100644 --- a/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx +++ b/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx @@ -1,4 +1,5 @@ import { CLERK_ADD_ACCOUNT } from '@clerk/shared/internal/clerk-js/constants'; +import { buildURL } from '@clerk/shared/internal/clerk-js/url'; import { Action, Actions } from '@/ui/elements/Actions'; import { Card } from '@/ui/elements/Card'; @@ -11,28 +12,26 @@ import { withRedirectToAfterSignIn } from '../../common'; import { useEnvironment, useSignInContext, useSignOutContext } from '../../contexts'; import { Col, descriptors, Flow, localizationKeys } from '../../customizables'; import { Add, ArrowRight } from '../../icons'; -import { useRouter } from '../../router'; import { SignOutAllActions } from '../UserButton/SessionActions'; import { useMultisessionActions } from '../UserButton/useMultisessionActions'; const SignInAccountSwitcherInternal = () => { const card = useCardState(); const { userProfileUrl } = useEnvironment().displayConfig; - const { afterSignInUrl, path: signInPath, signInUrl, taskUrl } = useSignInContext(); + const { afterSignInUrl, signInUrl, taskUrl } = useSignInContext(); const { navigateAfterSignOut } = useSignOutContext(); - const { queryParams } = useRouter(); - const addAccountUrl = new URL((signInPath ?? signInUrl) || window.location.href, window.location.origin); - if (queryParams.redirect_url && !addAccountUrl.searchParams.has('redirect_url')) { - addAccountUrl.searchParams.set('redirect_url', queryParams.redirect_url); - } - addAccountUrl.searchParams.set(CLERK_ADD_ACCOUNT, 'true'); + // signInUrl already carries the current query (incl. redirect_url) in the fragment, which both routers read. + const addAccountUrl = buildURL( + { base: signInUrl, hashSearchParams: { [CLERK_ADD_ACCOUNT]: 'true' } }, + { stringify: true }, + ); const { handleSignOutAllClicked, handleSessionClicked, signedInSessions, handleAddAccountClicked } = useMultisessionActions({ taskUrl, navigateAfterSignOut, afterSwitchSessionUrl: afterSignInUrl, userProfileUrl, - signInUrl: addAccountUrl.toString(), + signInUrl: addAccountUrl, user: undefined, }); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx index 8c33105eac6..dc4c154c9be 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx @@ -56,9 +56,9 @@ describe('SignInAccountSwitcher', () => { const { wrapper } = await createFixturesWithRedirect(initConfig); const { userEvent, getByText } = render(, { wrapper }); await userEvent.click(getByText('Add account')); - expect(clerkWindowNavigate).toHaveBeenCalledWith( + expect(clerkWindowNavigate).toHaveBeenLastCalledWith( expect.anything(), - expect.stringContaining('redirect_url=https%3A%2F%2Fexample.com%2Fconsent'), + expect.stringMatching(/redirect_url=https%3A%2F%2Fexample\.com%2Fconsent.*__clerk_add_account=true/), ); }); From 6b73cfb626b6177e8b70c241c3f8dc67c6537834 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Tue, 8 Sep 2026 14:16:46 -0400 Subject: [PATCH 4/4] fix(ui): keep multiSessionStart switcher redirect off the sign-in flow - Snapshot signed-in sessions on mount so completing a sign-in never swaps the form for the switcher - Redirect to the switcher with a history replace so Back leaves sign-in - Carry `__clerk_add_account` through the OAuth/email-link callback so a failed add-account attempt shows its error - Set the add-account flag from every "Add account" entry point via a shared `buildAddAccountUrl` - Make the account-switcher guard the outermost HOC so the single-session check is load-bearing Co-Authored-By: Claude Fable 5.1 --- .changeset/signin-multisession-start.md | 2 +- packages/shared/src/internal/clerk-js/url.ts | 9 +++ packages/shared/src/types/clerk.ts | 1 + .../SignIn/SignInAccountSwitcher.tsx | 10 +--- .../ui/src/components/SignIn/SignInStart.tsx | 47 ++++++++-------- .../__tests__/SignInAccountSwitcher.test.tsx | 2 +- .../SignIn/__tests__/SignInStart.test.tsx | 56 ++++++++++++++----- .../UserButton/__tests__/UserButton.test.tsx | 18 +++++- .../UserButton/useMultisessionActions.tsx | 3 +- packages/ui/src/contexts/components/SignIn.ts | 13 ++++- .../__tests__/user-button.model.test.tsx | 2 +- .../mosaic/user-button/user-button.model.tsx | 5 +- 12 files changed, 113 insertions(+), 55 deletions(-) diff --git a/.changeset/signin-multisession-start.md b/.changeset/signin-multisession-start.md index 271f3639c84..d8874431ba4 100644 --- a/.changeset/signin-multisession-start.md +++ b/.changeset/signin-multisession-start.md @@ -3,4 +3,4 @@ '@clerk/shared': minor --- -Add a `multiSessionStart` prop to ``. On multi-session instances, setting it to `'switcher'` starts a signed-in visitor on the account switcher (listing the signed-in accounts, with "Add account" and "Sign out of all accounts") instead of the identifier form, so flows that route through sign-in such as OAuth authorization can continue with an existing account. The default `'form'` keeps the current behavior, and the prop is ignored in single-session mode. The switcher's "Add account" action now also preserves the current `redirect_url`, so the newly added account continues where the flow left off. +Add a `multiSessionStart` prop to ``. On multi-session instances, `'switcher'` starts a signed-in visitor on the account switcher instead of the identifier form, so flows that route through sign-in (such as OAuth authorization) can continue with an existing account. Defaults to `'form'`; ignored in single-session mode. "Add account" from the switcher now preserves the current `redirect_url`; from the switcher and the `` it opens the sign-in form directly instead of returning to the switcher. diff --git a/packages/shared/src/internal/clerk-js/url.ts b/packages/shared/src/internal/clerk-js/url.ts index a9216b9a7db..de43bf8f673 100644 --- a/packages/shared/src/internal/clerk-js/url.ts +++ b/packages/shared/src/internal/clerk-js/url.ts @@ -4,6 +4,7 @@ import { logger } from '../../logger'; import type { SignUpResource } from '../../types'; import { camelToSnake } from '../../underscore'; import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '../../url'; +import { CLERK_ADD_ACCOUNT } from './constants'; import { joinPaths } from './path'; import { getQueryParams } from './querystring'; @@ -156,6 +157,14 @@ export function buildURL(params: BuildURLParams, options: BuildURLOptions { + return buildURL({ base, hashSearchParams: { [CLERK_ADD_ACCOUNT]: 'true' } }, { stringify: true }); +}; + export function toURL(url: string | URL): URL { return new URL(url.toString(), window.location.origin); } diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 1e831ae1219..76308bc46c3 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1906,6 +1906,7 @@ export type SignInProps = RoutingOptions & { * On multi-session instances, where a signed-in visitor lands when opening the sign-in component. * `'form'` renders the identifier form. `'switcher'` renders the account switcher listing the signed-in accounts, * with "Add account" and "Sign out of all accounts". Ignored in single-session mode. + * "Add account" navigates to the sign-in page, so in a modal it leaves the current page. * * @default 'form' */ diff --git a/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx b/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx index 31e6953d602..d2f2f620d98 100644 --- a/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx +++ b/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx @@ -1,6 +1,3 @@ -import { CLERK_ADD_ACCOUNT } from '@clerk/shared/internal/clerk-js/constants'; -import { buildURL } from '@clerk/shared/internal/clerk-js/url'; - import { Action, Actions } from '@/ui/elements/Actions'; import { Card } from '@/ui/elements/Card'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; @@ -20,18 +17,13 @@ const SignInAccountSwitcherInternal = () => { const { userProfileUrl } = useEnvironment().displayConfig; const { afterSignInUrl, signInUrl, taskUrl } = useSignInContext(); const { navigateAfterSignOut } = useSignOutContext(); - // signInUrl already carries the current query (incl. redirect_url) in the fragment, which both routers read. - const addAccountUrl = buildURL( - { base: signInUrl, hashSearchParams: { [CLERK_ADD_ACCOUNT]: 'true' } }, - { stringify: true }, - ); const { handleSignOutAllClicked, handleSessionClicked, signedInSessions, handleAddAccountClicked } = useMultisessionActions({ taskUrl, navigateAfterSignOut, afterSwitchSessionUrl: afterSignInUrl, userProfileUrl, - signInUrl: addAccountUrl, + signInUrl, user: undefined, }); diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 5d6e6f20c1c..2004cc8e65f 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -29,6 +29,7 @@ import type { SignInStartIdentifier } from '../../common'; import { getIdentifierControlDisplayValues, groupIdentifiers, + withRedirect, withRedirectToAfterSignIn, withRedirectToSignInTask, } from '../../common'; @@ -39,6 +40,7 @@ import { useLoadingStatus } from '../../hooks'; import { useSupportEmail } from '../../hooks/useSupportEmail'; import { useTotalEnabledAuthMethods } from '../../hooks/useTotalEnabledAuthMethods'; import { useRouter } from '../../router'; +import type { AvailableComponentProps } from '../../types'; import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer'; import { navigateOnSignInProtectGate } from './handleProtectCheck'; import { @@ -798,34 +800,33 @@ const InstantPasswordRow = ({ ); }; -const withRedirectToAccountSwitcher =

(Component: ComponentType

) => { +const withRedirectToAccountSwitcher =

(Component: ComponentType

) => { + const displayName = Component.displayName || Component.name || 'Component'; + Component.displayName = displayName; + const HOC = (props: P) => { const clerk = useClerk(); - const { authConfig } = useEnvironment(); const { multiSessionStart } = useSignInContext(); - const { navigate, queryParams } = useRouter(); - - const shouldShowSwitcher = - multiSessionStart === 'switcher' && - !authConfig.singleSessionMode && - clerk.client.signedInSessions.length > 0 && - queryParams[CLERK_ADD_ACCOUNT] === undefined; - - useEffect(() => { - if (shouldShowSwitcher) { - void navigate('choose'); - } - }, [shouldShowSwitcher, navigate]); - - if (shouldShowSwitcher) { - return null; - } - return ; + const { queryParams } = useRouter(); + // Snapshot on mount: the sign-in POST adds a session before setActive navigates; keep the form until then. + const [hadSignedInSessions] = useState(() => clerk.client.signedInSessions.length > 0); + + return withRedirect( + Component, + (_, environment) => + multiSessionStart === 'switcher' && + !environment?.authConfig.singleSessionMode && + hadSignedInSessions && + queryParams[CLERK_ADD_ACCOUNT] === undefined, + () => 'choose', + undefined, + { replace: true }, + )(props); }; - HOC.displayName = `withRedirectToAccountSwitcher(${Component.displayName || Component.name || 'Component'})`; + HOC.displayName = `withRedirectToAccountSwitcher(${displayName})`; return HOC; }; -export const SignInStart = withRedirectToSignInTask( - withRedirectToAfterSignIn(withRedirectToAccountSwitcher(withCardStateProvider(SignInStartInternal))), +export const SignInStart = withRedirectToAccountSwitcher( + withRedirectToSignInTask(withRedirectToAfterSignIn(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 dc4c154c9be..9b14f3b69ac 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx @@ -43,7 +43,7 @@ describe('SignInAccountSwitcher', () => { const { wrapper } = await createFixtures(initConfig); const { userEvent, getByText } = render(, { wrapper }); await userEvent.click(getByText('Add account')); - expect(clerkWindowNavigate).toHaveBeenCalledWith( + expect(clerkWindowNavigate).toHaveBeenLastCalledWith( expect.anything(), expect.stringContaining('__clerk_add_account=true'), ); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 51bf03efee8..3fe337064cc 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -1,7 +1,7 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; -import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; +import { CAPTCHA_ELEMENT_ID, CLERK_ADD_ACCOUNT } from '@clerk/shared/internal/clerk-js/constants'; import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; -import type { SignInResource } from '@clerk/shared/types'; +import type { SignedInSessionResource, SignInResource } from '@clerk/shared/types'; import { waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -67,24 +67,30 @@ describe('SignInStart', () => { }); describe('multi-session start', () => { - const withSignedInSessions = (f: Parameters[0]>[0]) => { + const withSignedInSessions = createFixtures.config(f => { f.withEmailAddress(); f.withMultiSessionMode(); f.withUser({ email_addresses: ['test1@clerk.com'] }); - }; + }); + const navigations = (fixtures: Awaited>['fixtures']) => + fixtures.router.navigate.mock.calls.map(([to]) => to); + const { createFixtures: createFixturesWithAddAccount } = bindCreateFixtures('SignIn', { + router: { queryParams: { [CLERK_ADD_ACCOUNT]: 'true' } }, + }); it('renders the identifier form when the prop is unset and signed-in sessions exist', async () => { const { wrapper, fixtures } = await createFixtures(withSignedInSessions); render(, { wrapper }); screen.getAllByText(/sign in to .*/i); - expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); + expect(navigations(fixtures)).not.toContain('choose'); }); - it('redirects to the account switcher when the prop is "switcher" and signed-in sessions exist', async () => { + it('replaces the route with the account switcher when the prop is "switcher" and signed-in sessions exist', async () => { const { wrapper, fixtures, props } = await createFixtures(withSignedInSessions); props.setProps({ multiSessionStart: 'switcher' }); render(, { wrapper }); - await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('choose')); + await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('choose', { replace: true })); + expect(fixtures.router.navigate).toHaveBeenCalledTimes(1); expect(screen.queryByText(/sign in to .*/i)).toBeNull(); }); @@ -96,18 +102,42 @@ describe('SignInStart', () => { props.setProps({ multiSessionStart: 'switcher' }); render(, { wrapper }); screen.getAllByText(/sign in to .*/i); - expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); + expect(navigations(fixtures)).not.toContain('choose'); }); - it('renders the identifier form when the add-account param is set', async () => { - const { createFixtures: createFixturesWithAddAccount } = bindCreateFixtures('SignIn', { - router: { queryParams: { __clerk_add_account: 'true' } }, + it('keeps the identifier form when a session appears after mount', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withMultiSessionMode(); }); + props.setProps({ multiSessionStart: 'switcher' }); + const { rerender } = render(, { wrapper }); + vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([ + { id: 'sess_1' } as unknown as SignedInSessionResource, + ]); + rerender(); + screen.getAllByText(/sign in to .*/i); + expect(navigations(fixtures)).not.toContain('choose'); + }); + + it('renders the identifier form when the add-account param is set', async () => { const { wrapper, fixtures, props } = await createFixturesWithAddAccount(withSignedInSessions); props.setProps({ multiSessionStart: 'switcher' }); render(, { wrapper }); screen.getAllByText(/sign in to .*/i); - expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); + expect(navigations(fixtures)).not.toContain('choose'); + }); + + it('carries the add-account param through the OAuth callback URL', async () => { + const { wrapper, fixtures } = await createFixturesWithAddAccount(f => { + f.withMultiSessionMode(); + f.withSocialProvider({ provider: 'google' }); + }); + const { userEvent } = render(, { wrapper }); + await userEvent.click(screen.getByText('Continue with Google')); + expect(fixtures.signIn.authenticateWithRedirect).toHaveBeenCalledWith( + expect.objectContaining({ redirectUrl: expect.stringContaining('__clerk_add_account=true') }), + ); }); it('does not redirect to the account switcher in single-session mode', async () => { @@ -117,7 +147,7 @@ describe('SignInStart', () => { }); props.setProps({ multiSessionStart: 'switcher' }); render(, { wrapper }); - expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); + expect(navigations(fixtures)).not.toContain('choose'); }); }); diff --git a/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx b/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx index 06d19655215..b8c9009eba4 100644 --- a/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx +++ b/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx @@ -1,12 +1,15 @@ import { UNSAFE_PortalProvider } from '@clerk/shared/react'; import React from 'react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, screen, waitFor } from '@/test/utils'; +import { clerkWindowNavigate } from '@/ui/utils/windowNavigate'; import { UserButton } from '../'; +vi.mock('@/ui/utils/windowNavigate', () => ({ clerkWindowNavigate: vi.fn() })); + const { createFixtures } = bindCreateFixtures('UserButton'); describe('UserButton', () => { @@ -87,8 +90,6 @@ describe('UserButton', () => { expect(fixtures.router.navigate).toHaveBeenCalledWith('/'); }); - it.todo('navigates to sign in url when "Add account" is clicked'); - describe('UserButton with PortalProvider', () => { it('passes getContainer to openUserProfile when wrapped in PortalProvider', async () => { const container = document.createElement('div'); @@ -157,6 +158,17 @@ describe('UserButton', () => { expect(getByText('First3 Last3')).toBeDefined(); }); + it('navigates to the sign-in URL with the add-account param when "Add account" is clicked', async () => { + const { wrapper } = await createFixtures(initConfig); + const { getByText, getByRole, userEvent } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: 'Open user menu' })); + await userEvent.click(getByText('Add account')); + expect(clerkWindowNavigate).toHaveBeenLastCalledWith( + expect.anything(), + expect.stringContaining('__clerk_add_account=true'), + ); + }); + it('changes the active session when clicking another session', async () => { const { wrapper, fixtures } = await createFixtures(initConfig); fixtures.clerk.setActive.mockReturnValueOnce(Promise.resolve()); diff --git a/packages/ui/src/components/UserButton/useMultisessionActions.tsx b/packages/ui/src/components/UserButton/useMultisessionActions.tsx index bb46235f382..48a0c736a7e 100644 --- a/packages/ui/src/components/UserButton/useMultisessionActions.tsx +++ b/packages/ui/src/components/UserButton/useMultisessionActions.tsx @@ -1,4 +1,5 @@ import { navigateIfTaskExists } from '@clerk/shared/internal/clerk-js/sessionTasks'; +import { buildAddAccountUrl } from '@clerk/shared/internal/clerk-js/url'; import { useClerk, usePortalRoot } from '@clerk/shared/react'; import type { SignedInSessionResource, UserButtonProps, UserResource } from '@clerk/shared/types'; @@ -102,7 +103,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { }; const handleAddAccountClicked = () => { - clerkWindowNavigate(clerk, opts.signInUrl || window.location.href); + clerkWindowNavigate(clerk, buildAddAccountUrl(opts.signInUrl || window.location.href)); return sleep(2000); }; diff --git a/packages/ui/src/contexts/components/SignIn.ts b/packages/ui/src/contexts/components/SignIn.ts index 2d44b0bb838..b1e1058cb1b 100644 --- a/packages/ui/src/contexts/components/SignIn.ts +++ b/packages/ui/src/contexts/components/SignIn.ts @@ -1,4 +1,8 @@ -import { SIGN_IN_INITIAL_VALUE_KEYS, SIGN_UP_MODES } from '@clerk/shared/internal/clerk-js/constants'; +import { + CLERK_ADD_ACCOUNT, + SIGN_IN_INITIAL_VALUE_KEYS, + SIGN_UP_MODES, +} from '@clerk/shared/internal/clerk-js/constants'; import { RedirectUrls } from '@clerk/shared/internal/clerk-js/redirectUrls'; import { getTaskEndpoint } from '@clerk/shared/internal/clerk-js/sessionTasks'; import { buildURL } from '@clerk/shared/internal/clerk-js/url'; @@ -101,7 +105,12 @@ export const useSignInContext = (): SignInContextType => { signUpUrl = buildURL({ base: signUpUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); waitlistUrl = buildURL({ base: waitlistUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); - const authQueryString = redirectUrls.toSearchParams().toString(); + const authSearchParams = redirectUrls.toSearchParams(); + if (queryParams[CLERK_ADD_ACCOUNT]) { + // Survives the OAuth / email-link round trip so a failed add-account attempt lands back on the form. + authSearchParams.set(CLERK_ADD_ACCOUNT, queryParams[CLERK_ADD_ACCOUNT]); + } + const authQueryString = authSearchParams.toString(); // Callback routes owned by the SignIn tree are always SignIn-rooted — including the combined-flow // branches mounted at `create/sso-callback` and `create/verify` under the SignIn component diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx index 7f4ee1ea062..1e2e39d9bd0 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx @@ -789,7 +789,7 @@ describe('useUserButtonModel', () => { expect(navigate).not.toHaveBeenCalled(); fireEvent.click(screen.getByText('add-account')); - expect(navigate).toHaveBeenCalledWith('/sign-in'); + expect(navigate).toHaveBeenCalledWith('http://localhost:3000/sign-in#/?__clerk_add_account=true'); }); it('navigates to a create-organization URL when one is given', () => { diff --git a/packages/ui/src/mosaic/user-button/user-button.model.tsx b/packages/ui/src/mosaic/user-button/user-button.model.tsx index 20404039508..58360116214 100644 --- a/packages/ui/src/mosaic/user-button/user-button.model.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.model.tsx @@ -1,4 +1,5 @@ import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks'; +import { buildAddAccountUrl } from '@clerk/shared/internal/clerk-js/url'; import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user'; import { useClerk, useOrganization, usePortalRoot, useSession, useUser } from '@clerk/shared/react'; import type { CustomPage, OrganizationResource, UserResource } from '@clerk/shared/types'; @@ -292,7 +293,9 @@ export function useUserButtonModel( onInviteMembers: canInviteMembers ? () => clerk.openInviteMembers({ getContainer }) : undefined, // Covers both restricted instances and users at their creation limit. onCreateOrganization: user.createOrganizationEnabled ? createOrganization : undefined, - onAddAccount: singleSessionMode ? undefined : () => void router.navigate(clerk.buildSignInUrl()), + onAddAccount: singleSessionMode + ? undefined + : () => void router.navigate(buildAddAccountUrl(clerk.buildSignInUrl())), onAcceptSuggestion: async suggestionId => { const suggestion = suggestionData.find(s => s.id === suggestionId); try {