diff --git a/.changeset/signin-multisession-start.md b/.changeset/signin-multisession-start.md
new file mode 100644
index 00000000000..d8874431ba4
--- /dev/null
+++ b/.changeset/signin-multisession-start.md
@@ -0,0 +1,6 @@
+---
+'@clerk/ui': minor
+'@clerk/shared': minor
+---
+
+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/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/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 49f7875870c..1e831ae1219 100644
--- a/packages/shared/src/types/clerk.ts
+++ b/packages/shared/src/types/clerk.ts
@@ -1902,6 +1902,14 @@ 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'
+ */
+ 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..9ccd4ea6ffa 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,23 +11,31 @@ 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 = () => {
+type SignInAccountSwitcherProps = {
+ // Route of the sign-in start screen relative to where the switcher is mounted.
+ addAccountPath?: string;
+};
+
+const SignInAccountSwitcherInternal = ({ addAccountPath = '..' }: SignInAccountSwitcherProps) => {
const card = useCardState();
const { userProfileUrl } = useEnvironment().displayConfig;
- const { afterSignInUrl, path: signInPath, signInUrl, taskUrl } = useSignInContext();
+ const { afterSignInUrl, signInUrl, taskUrl } = useSignInContext();
const { navigateAfterSignOut } = useSignOutContext();
- const { handleSignOutAllClicked, handleSessionClicked, signedInSessions, handleAddAccountClicked } =
- useMultisessionActions({
- taskUrl,
- navigateAfterSignOut,
- afterSwitchSessionUrl: afterSignInUrl,
- userProfileUrl,
- signInUrl: signInPath ?? signInUrl,
- user: undefined,
- });
+ const { navigate } = useRouter();
+ const { handleSignOutAllClicked, handleSessionClicked, signedInSessions } = useMultisessionActions({
+ taskUrl,
+ navigateAfterSignOut,
+ afterSwitchSessionUrl: afterSignInUrl,
+ userProfileUrl,
+ signInUrl,
+ user: undefined,
+ });
+ const handleAddAccountClicked = () =>
+ navigate(addAccountPath, { searchParams: new URLSearchParams({ [CLERK_ADD_ACCOUNT]: 'true' }) });
return (
diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx
index a95040b8465..6e8ff6f986b 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';
@@ -45,6 +45,7 @@ import {
SIGN_IN_RESET_PASSWORD_INTENT_PARAM,
useHandleAuthenticateWithPasskey,
} from './shared';
+import { SignInAccountSwitcher } from './SignInAccountSwitcher';
import { SignInAlternativePhoneCodePhoneNumberCard } from './SignInAlternativePhoneCodePhoneNumberCard';
import { SignInSocialButtons } from './SignInSocialButtons';
import {
@@ -797,6 +798,21 @@ const InstantPasswordRow = ({
);
};
-export const SignInStart = withRedirectToSignInTask(
- withRedirectToAfterSignIn(withCardStateProvider(SignInStartInternal)),
-);
+const SignInStartCard = withRedirectToSignInTask(withRedirectToAfterSignIn(withCardStateProvider(SignInStartInternal)));
+
+export const SignInStart = () => {
+ const clerk = useClerk();
+ const { authConfig } = useEnvironment();
+ const { multiSessionStart } = useSignInContext();
+ 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);
+
+ const showSwitcher =
+ multiSessionStart === 'switcher' &&
+ !authConfig.singleSessionMode &&
+ hadSignedInSessions &&
+ queryParams[CLERK_ADD_ACCOUNT] === undefined;
+
+ return showSwitcher ? : ;
+};
diff --git a/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx
index 54a8cd799de..f00d87128e6 100644
--- a/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx
+++ b/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx
@@ -36,12 +36,20 @@ 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 () => {
+ it('navigates to the sign-in start screen with the add-account param when "Add account" is clicked', async () => {
const { wrapper, fixtures } = await createFixtures(initConfig);
const { userEvent, getByText } = render(, { wrapper });
await userEvent.click(getByText('Add account'));
- expect(fixtures.router.navigate).toHaveBeenCalled();
+ expect(fixtures.router.navigate).toHaveBeenCalledWith('..', {
+ searchParams: new URLSearchParams({ __clerk_add_account: 'true' }),
+ });
+ });
+
+ it('navigates to the given start screen path when rendered in place', async () => {
+ const { wrapper, fixtures } = await createFixtures(initConfig);
+ const { userEvent, getByText } = render(, { wrapper });
+ await userEvent.click(getByText('Add account'));
+ expect(fixtures.router.navigate).toHaveBeenCalledWith('.', expect.anything());
});
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 01bd5d4e3a0..e5b2ec061bd 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, ClerkWebAuthnError } 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';
@@ -66,6 +66,93 @@ describe('SignInStart', () => {
screen.getAllByText(/sign in to .*/i);
});
+ describe('multi-session start', () => {
+ const withSignedInSessions = createFixtures.config(f => {
+ f.withEmailAddress();
+ f.withMultiSessionMode();
+ f.withUser({ email_addresses: ['test1@clerk.com'] });
+ });
+ const { createFixtures: createFixturesWithAddAccount } = bindCreateFixtures('SignIn', {
+ router: { queryParams: { [CLERK_ADD_ACCOUNT]: 'true' } },
+ });
+ const expectForm = () => {
+ screen.getAllByText(/sign in to .*/i);
+ expect(screen.queryByText('Add account')).toBeNull();
+ };
+ const expectSwitcher = () => {
+ screen.getByText('Add account');
+ expect(screen.queryByText(/sign in to .*/i)).toBeNull();
+ };
+
+ it('renders the identifier form when the prop is unset and signed-in sessions exist', async () => {
+ const { wrapper } = await createFixtures(withSignedInSessions);
+ render(, { wrapper });
+ expectForm();
+ });
+
+ it('renders 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 });
+ expectSwitcher();
+ expect(fixtures.router.navigate).not.toHaveBeenCalled();
+ });
+
+ it('renders the identifier form when the prop is "switcher" and no signed-in sessions exist', async () => {
+ const { wrapper, props } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withMultiSessionMode();
+ });
+ props.setProps({ multiSessionStart: 'switcher' });
+ render(, { wrapper });
+ expectForm();
+ });
+
+ 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();
+ expectForm();
+ });
+
+ it('renders the identifier form when the add-account param is set', async () => {
+ const { wrapper, props } = await createFixturesWithAddAccount(withSignedInSessions);
+ props.setProps({ multiSessionStart: 'switcher' });
+ render(, { wrapper });
+ expectForm();
+ });
+
+ 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('ignores the prop in single-session mode', async () => {
+ const { wrapper, fixtures, props } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withUser({ email_addresses: ['test1@clerk.com'] });
+ });
+ props.setProps({ multiSessionStart: 'switcher' });
+ render(, { wrapper });
+ expect(screen.queryByText('Add account')).toBeNull();
+ expect(fixtures.router.navigate).toHaveBeenCalledWith('/');
+ });
+ });
+
describe('Login Methods', () => {
it('enables login with email address', async () => {
const { wrapper } = await createFixtures(f => {
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 {
diff --git a/packages/ui/src/router/HashRouter.tsx b/packages/ui/src/router/HashRouter.tsx
index 1d1e2e8b452..0de46158a18 100644
--- a/packages/ui/src/router/HashRouter.tsx
+++ b/packages/ui/src/router/HashRouter.tsx
@@ -18,7 +18,9 @@ export const HashRouter = ({ preservedParams, children }: HashRouterProps): JSX.
if (!toURL) {
return;
}
- window.location.hash = stripOrigin(toURL).substring(1 + hashRouterBase.length);
+ const hash = stripOrigin(toURL).substring(1 + hashRouterBase.length);
+ // The index route with a query would otherwise become `#?x`, which is not read as a fragment URL.
+ window.location.hash = hash.startsWith('?') ? '/' + hash : hash;
return Promise.resolve();
};
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'));
+ });
+ });
});
diff --git a/packages/ui/src/router/__tests__/HashRouter.test.tsx b/packages/ui/src/router/__tests__/HashRouter.test.tsx
index ab6ee1c17a8..b0720013e09 100644
--- a/packages/ui/src/router/__tests__/HashRouter.test.tsx
+++ b/packages/ui/src/router/__tests__/HashRouter.test.tsx
@@ -25,12 +25,16 @@ vi.mock('@clerk/shared/react', () => {
};
});
-const Button = ({ to, children }: React.PropsWithChildren<{ to: string }>) => {
+const Button = ({
+ to,
+ searchParams,
+ children,
+}: React.PropsWithChildren<{ to: string; searchParams?: URLSearchParams }>) => {
const router = useRouter();
return (