diff --git a/.changeset/mosaic-profile-modal-oauth-return.md b/.changeset/mosaic-profile-modal-oauth-return.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-profile-modal-oauth-return.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx
new file mode 100644
index 00000000000..32349ada5ac
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx
@@ -0,0 +1,59 @@
+import { configure, screen, within } from '@testing-library/react';
+import { beforeAll, describe, expect, it, onTestFinished } from 'vitest';
+import { page } from 'vitest/browser';
+
+import { serveFapi } from '../../../__tests__/feature/fake-fapi';
+import { fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+
+beforeAll(() => page.viewport(1280, 800));
+
+function serve() {
+ serveFapi({
+ environment: fapiEnvironment(),
+ client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]),
+ });
+}
+
+function visit(search: string) {
+ const original = window.location.href;
+ window.history.replaceState(null, '', `${window.location.pathname}${search}#accounts`);
+ onTestFinished(() => window.history.replaceState(null, '', original));
+}
+
+function strictMode() {
+ configure({ reactStrictMode: true });
+ onTestFinished(() => configure({ reactStrictMode: false }));
+}
+
+describe('UserProfile modal return', () => {
+ it.each([
+ { name: 'renders normally', strict: false },
+ { name: 'renders in StrictMode', strict: true },
+ ])('reopens the profile on the account page and clears the param when it $name', async ({ strict }) => {
+ if (strict) {
+ strictMode();
+ }
+ serve();
+ visit('?__clerk_mosaic_modal=user-profile%2Faccount&tab=connections');
+
+ await renderWithClerk(
Page
);
+
+ const dialog = await screen.findByRole('dialog');
+ expect(await within(dialog).findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true');
+ expect(window.location.search).toBe('?tab=connections');
+ expect(window.location.hash).toBe('#accounts');
+ });
+
+ it('ignores an unknown value', async () => {
+ serve();
+ visit('?__clerk_mosaic_modal=organization-profile%2Fgeneral&tab=connections');
+
+ await renderWithClerk(Page
);
+
+ expect(await screen.findByText('Page')).toBeVisible();
+ expect(screen.queryByRole('dialog')).toBeNull();
+ expect(window.location.search).toBe('?__clerk_mosaic_modal=organization-profile%2Fgeneral&tab=connections');
+ expect(window.location.hash).toBe('#accounts');
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx
index 2595e112eaf..1d6d556d55a 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx
@@ -214,7 +214,7 @@ describe('connected accounts', () => {
expect(await screen.findByText('The connection could not start. Please try again.')).toBeInTheDocument();
});
- it('preserves modal state when connecting', async () => {
+ it('returns to the profile modal after connecting', async () => {
serveFapi(signedIn([]));
await renderWithClerk(
@@ -226,12 +226,27 @@ describe('connected accounts', () => {
await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' }));
await waitFor(() => expect(request.requests).toHaveLength(1));
const body = new URLSearchParams(await request.requests[0]?.text());
- const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state');
- expect(encoded).toBeTruthy();
- expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({
- componentName: 'UserProfile',
- socialProvider: 'github',
- });
+ const params = new URL(body.get('redirect_url') || '').searchParams;
+ expect(params.get('__clerk_mosaic_modal')).toBe('user-profile/account');
+ expect(params.has('__clerk_modal_state')).toBe(false);
+ request.fail();
+ });
+
+ it('returns to the page without modal state when connecting from a mounted profile', async () => {
+ serveFapi(signedIn([]));
+ await renderWithClerk(
+
+
+ ,
+ );
+ const request = holdRequests('post', '/v1/me/external_accounts');
+
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ const body = new URLSearchParams(await request.requests[0]?.text());
+ const params = new URL(body.get('redirect_url') || '').searchParams;
+ expect(params.has('__clerk_mosaic_modal')).toBe(false);
+ expect(params.has('__clerk_modal_state')).toBe(false);
request.fail();
});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx
index 2daba3a4ef1..be98c19e55a 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx
@@ -99,7 +99,7 @@ describe('connected accounts', () => {
expect(open).not.toHaveBeenCalled();
});
- it('preserves modal state when reconnecting', async () => {
+ it('returns to the profile modal after reconnecting', async () => {
serveFapi(signedIn([disconnectedGoogle]));
await renderWithClerk(
@@ -112,9 +112,9 @@ describe('connected accounts', () => {
await startReconnect(user, 'Google');
await waitFor(() => expect(request.requests).toHaveLength(1));
const body = new URLSearchParams(await request.requests[0]?.text());
- const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state');
- expect(encoded).toBeTruthy();
- expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({ componentName: 'UserProfile' });
+ const params = new URL(body.get('redirect_url') || '').searchParams;
+ expect(params.get('__clerk_mosaic_modal')).toBe('user-profile/account');
+ expect(params.has('__clerk_modal_state')).toBe(false);
request.fail();
});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
index e0cc0b7249d..93e85423c7c 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -1,6 +1,5 @@
import { iconImageUrl } from '@clerk/shared/constants';
import { ClerkRuntimeError } from '@clerk/shared/error';
-import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams';
import { OAUTH_PROVIDERS } from '@clerk/shared/oauth';
import { useClerk, useUser } from '@clerk/shared/react';
import type {
@@ -20,6 +19,7 @@ import { useMosaicRouter } from '../../../hooks/use-mosaic-router';
import { allowsIdentificationCreation } from '../../../utils/allows-identification-creation';
import type { ReverificationController } from '../../reverification';
import { useReverificationFlow } from '../../reverification';
+import { withUserProfileModalReturn } from '../user-profile.modal-return';
import { useUserProfileOptions } from '../user-profile.provider';
import { isMonochromeProvider } from '../user-profile-provider.utils';
import type {
@@ -283,8 +283,7 @@ export function useUserProfileConnectedAccountsModel(): UserProfileConnectedAcco
};
const getRedirectUrl = async () => (transport ? String(await transport.getRedirectUrl()) : window.location.href);
- const withModalState = (url: string, socialProvider?: string) =>
- mode === 'modal' ? appendModalState({ url, componentName: 'UserProfile', socialProvider }) : url;
+ const withModalState = (url: string) => (mode === 'modal' ? withUserProfileModalReturn(url) : url);
const completeVerification = async (response: ExternalAccountResource): Promise => {
const url = response.verification?.externalVerificationRedirectURL;
@@ -319,7 +318,7 @@ export function useUserProfileConnectedAccountsModel(): UserProfileConnectedAcco
const current = requireCurrentUser();
const response = await createAccount(current, {
strategy: provider.strategy,
- redirectUrl: withModalState(redirectUrl, provider.provider),
+ redirectUrl: withModalState(redirectUrl),
additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider.provider] : [],
});
return completeVerification(response);
diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts
new file mode 100644
index 00000000000..b56945f08bd
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts
@@ -0,0 +1,29 @@
+import React from 'react';
+
+import { useModal } from '../../modals/use-modal';
+import { userProfileModal } from './user-profile.modal';
+
+const MODAL_PARAM = '__clerk_mosaic_modal';
+const ACCOUNT_PAGE = 'user-profile/account';
+
+export function withUserProfileModalReturn(url: string): string {
+ const next = new URL(url);
+ next.searchParams.set(MODAL_PARAM, ACCOUNT_PAGE);
+ return next.toString();
+}
+
+export function UserProfileModalReturn(): null {
+ const { open } = useModal(userProfileModal);
+
+ React.useEffect(() => {
+ const url = new URL(window.location.href);
+ if (url.searchParams.get(MODAL_PARAM) !== ACCOUNT_PAGE) {
+ return;
+ }
+ url.searchParams.delete(MODAL_PARAM);
+ window.history.replaceState(null, '', url);
+ open({ page: 'account' });
+ }, [open]);
+
+ return null;
+}
diff --git a/packages/mosaic/src/mosaic-provider.tsx b/packages/mosaic/src/mosaic-provider.tsx
index 3d7355893ba..7f90a73d2c0 100644
--- a/packages/mosaic/src/mosaic-provider.tsx
+++ b/packages/mosaic/src/mosaic-provider.tsx
@@ -3,6 +3,7 @@ import React from 'react';
import { ToastProvider } from './components/toast/toast';
import { organizationProfileModal } from './features/organization-profile/organization-profile.modal';
import { userProfileModal } from './features/user-profile/user-profile.modal';
+import { UserProfileModalReturn } from './features/user-profile/user-profile.modal-return';
import type { MosaicExperimental } from './hooks/use-mosaic-experimental';
import { MosaicExperimentalProvider } from './hooks/use-mosaic-experimental';
import { MosaicNowProvider } from './hooks/use-now';
@@ -50,7 +51,10 @@ export function MosaicProvider({
- {children}
+
+
+ {children}
+