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);
};