From 31ff4b0c547027030aaa0ff196342fbd29c434a5 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 30 Oct 2026 10:47:19 -0400 Subject: [PATCH 1/4] feat(mosaic): reopen the user profile modal after OAuth and SSO redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect, Reconnect, enterprise connect and email SSO verification from the modal now carry __clerk_mosaic_modal=user-profile/account through the redirect. MosaicProvider removes the param on mount and reopens the profile on the Account page. Mosaic no longer writes __clerk_modal_state, which opens the legacy modal. Co-authored-by: Fredrik Höglund --- .../mosaic-profile-modal-oauth-return.md | 5 ++ .../user-button.new-profiles.feature.test.tsx | 8 +-- ...user-profile.modal-return.feature.test.tsx | 59 +++++++++++++++++++ ...onnected-accounts.connect.feature.test.tsx | 29 ++++++--- ...nected-accounts.reconnect.feature.test.tsx | 8 +-- ...rofile-connected-accounts-section.model.ts | 7 +-- ...ser-profile-email-section.feature.test.tsx | 28 +++++++++ .../user-profile-email-section.model.ts | 17 +++--- ...ofile-enterprise-accounts.feature.test.tsx | 26 ++++++-- ...ofile-enterprise-accounts-section.model.ts | 5 +- .../user-profile/user-profile.modal-return.ts | 33 +++++++++++ packages/mosaic/src/mosaic-provider.tsx | 6 +- 12 files changed, 195 insertions(+), 36 deletions(-) create mode 100644 .changeset/mosaic-profile-modal-oauth-return.md create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile.modal-return.ts diff --git a/.changeset/mosaic-profile-modal-oauth-return.md b/.changeset/mosaic-profile-modal-oauth-return.md new file mode 100644 index 00000000000..764431a4989 --- /dev/null +++ b/.changeset/mosaic-profile-modal-oauth-return.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +The user profile modal now reopens on the Account page after a user leaves it to connect or reconnect a social or enterprise account, or to verify an email address through enterprise SSO. Mosaic no longer sends `__clerk_modal_state` on these redirects, so returning from them no longer opens the legacy profile modal. diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx index b41f67b573a..5b49e03d37c 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx @@ -128,11 +128,9 @@ describe('UserButton with experimental.newProfiles', () => { 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(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(); }); 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..498e24fc37c 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) => withUserProfileModalReturn(url, mode); 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-email-section/__tests__/user-profile-email-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx index d28b064404a..a87c52839e3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx @@ -22,6 +22,7 @@ import { } from '../../../../__tests__/feature/fapi'; import { openMenu } from '../../../../__tests__/feature/menu'; import { renderWithClerk } from '../../../../__tests__/feature/render'; +import { UserProfileProvider } from '../../user-profile.provider'; import { UserProfileEmailSection } from '../user-profile-email-section'; const VERIFIED = fapiVerification('email_code', { status: 'verified' }); @@ -207,6 +208,33 @@ describe('the user profile email section', () => { await waitFor(() => expect(windowNavigate).toHaveBeenCalledWith(new URL('https://idp.acme.co/sso'))); }); + it('returns to the profile modal after verifying through the identity provider', async () => { + const sso = fapiEmailAddress({ + id: 'idn_sso', + email_address: 'alice@acme.co', + matches_sso_connection: true, + }); + serveFapi(signedInWithEmails([PRIMARY, sso])); + await renderWithClerk( + + + , + ); + const actor = userEvent.setup(); + const prepare = holdRequests('post', '/v1/me/email_addresses/:id/prepare_verification'); + + await manageEmail(actor, 'alice@acme.co', 'Verify'); + const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); + await actor.click(within(dialog).getByRole('button', { name: 'Connect' })); + + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + const body = new URLSearchParams(await prepare.requests[0]?.text()); + 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); + prepare.fail(); + }); + it('does not offer to add the username as an address', async () => { const { actor } = await renderSection( signedInWithEmails([], fapiEnvironment({ attributes: { username: { enabled: true } } })), diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts index b94eb88cfd5..fd4419a5ac8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts @@ -6,6 +6,9 @@ import { useMosaicRouter } from '../../../hooks/use-mosaic-router'; import { allowsIdentificationCreation } from '../../../utils/allows-identification-creation'; import { save } from '../../../utils/errors'; import { useReverificationFlow } from '../../reverification'; +import { withUserProfileModalReturn } from '../user-profile.modal-return'; +import type { UserProfileOptions } from '../user-profile.provider'; +import { useUserProfileOptions } from '../user-profile.provider'; import type { UserProfileEmailVerification, UserProfileEmailVerifier } from '../user-profile-contact.types'; import { byId, toContactAccess, toContacts } from '../user-profile-contact.utils'; import { useUserProfileUserModel } from '../user-profile-user.model'; @@ -33,6 +36,7 @@ function startEmailVerification( email: EmailAddressResource, linkRedirectUrl: string | undefined, router: MosaicRouter, + mode: UserProfileOptions['mode'], ): UserProfileEmailVerification { if (email.matchesSsoConnection) { return { @@ -41,17 +45,12 @@ function startEmailVerification( Prepared on the click rather than on entering the step, so the redirect URL the server answers with is in hand by the time we navigate. Preparing on entry leaves the button live before the response lands, and an early click has nowhere to go. - - TODO: carry the mounting mode back from the IdP. Legacy appends `appendModalState` to this - redirect when the profile is mounted as a modal, so returning from the provider reopens the - modal on the step the user left. Mosaic has no modal mode to encode yet; whoever adds one has - to encode it here too, or the user comes back to a closed dialog and a lost flow. */ connect: () => save(async () => { const prepared = await email.prepareVerification({ strategy: 'enterprise_sso', - redirectUrl: window.location.href, + redirectUrl: withUserProfileModalReturn(window.location.href, mode), }); const url = prepared.verification.externalVerificationRedirectURL; if (!url) { @@ -76,9 +75,10 @@ function toEmailVerifier( email: EmailAddressResource, linkRedirectUrl: string | undefined, router: MosaicRouter, + mode: UserProfileOptions['mode'], ): UserProfileEmailVerifier { return { - start: () => startEmailVerification(email, linkRedirectUrl, router), + start: () => startEmailVerification(email, linkRedirectUrl, router, mode), verifyCode: code => save(() => email.attemptVerification({ code }), ADD_EMAIL_FIELDS), }; } @@ -86,6 +86,7 @@ function toEmailVerifier( export function useUserProfileEmailSectionModel(): UserProfileEmailSectionModel { const model = useUserProfileUserModel(); const router = useMosaicRouter(); + const { mode } = useUserProfileOptions(); const [removeEmail, removeReverification] = useReverificationFlow((user: UserResource, id: string) => byId(user.emailAddresses, id, 'email address').destroy(), ); @@ -114,7 +115,7 @@ export function useUserProfileEmailSectionModel(): UserProfileEmailSectionModel const verifiesByLink = Boolean(attributes.email_address?.verifications.includes('email_link')); const linkRedirectUrl = verifiesByLink ? verifyRedirectUrl(environment.displayConfig.userProfileUrl) : undefined; - const verifierFor = (email: EmailAddressResource) => toEmailVerifier(email, linkRedirectUrl, router); + const verifierFor = (email: EmailAddressResource) => toEmailVerifier(email, linkRedirectUrl, router, mode); return { status: 'ready', diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx index b043c5dbbf7..9216357078c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx @@ -185,7 +185,7 @@ describe('enterprise accounts', () => { ); }); - it('preserves modal return state in the redirect URL', async () => { + it('returns to the profile modal after connecting', async () => { serveFapi(signedIn()); await renderWithClerk( @@ -197,9 +197,27 @@ describe('enterprise accounts', () => { await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); 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(); + }); + + 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(await screen.findByRole('button', { name: 'Connect Acme Okta' })); + 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-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts index f6666ce1740..5061855edac 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts @@ -1,5 +1,4 @@ import { ClerkRuntimeError } from '@clerk/shared/error'; -import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams'; import { __internal_useUserEnterpriseConnections, useClerk, useUser } from '@clerk/shared/react'; import type { EnterpriseAccountResource, EnterpriseConnectionResource, UserResource } from '@clerk/shared/types'; @@ -7,6 +6,7 @@ import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { useMosaicRouter } from '../../../hooks/use-mosaic-router'; 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 { @@ -137,8 +137,7 @@ export function useUserProfileEnterpriseAccountsModel(): UserProfileEnterpriseAc if (!projection.connections.some(connection => connection.id === connectionId)) { throw unavailable(); } - const url = window.location.href; - const redirectUrl = mode === 'modal' ? appendModalState({ url, componentName: 'UserProfile' }) : url; + const redirectUrl = withUserProfileModalReturn(window.location.href, mode); const account = await createAccount(current, { enterpriseConnectionId: connectionId, redirectUrl }); currentUser(); const redirect = account.verification?.externalVerificationRedirectURL; 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..8d49d3ce2ea --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts @@ -0,0 +1,33 @@ +import React from 'react'; + +import { useModal } from '../../modals/use-modal'; +import { userProfileModal } from './user-profile.modal'; +import type { UserProfileOptions } from './user-profile.provider'; + +const MODAL_PARAM = '__clerk_mosaic_modal'; +const ACCOUNT_PAGE = 'user-profile/account'; + +export function withUserProfileModalReturn(url: string, mode: UserProfileOptions['mode']): string { + if (mode !== 'modal') { + return url; + } + 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} + From b7b0b3caeab0c91f973351ebf578d3ee1d85cfaa Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 30 Oct 2026 11:36:06 -0400 Subject: [PATCH 2/4] fix(mosaic): keep history state when clearing the modal return param --- .../user-profile.modal-return.feature.test.tsx | 12 ++++++++++++ .../user-profile/user-profile.modal-return.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) 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 index 32349ada5ac..2d3bbb81b47 100644 --- 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 @@ -45,6 +45,18 @@ describe('UserProfile modal return', () => { expect(window.location.hash).toBe('#accounts'); }); + it('keeps the history state of the page when it clears the param', async () => { + serve(); + visit('?__clerk_mosaic_modal=user-profile%2Faccount'); + window.history.replaceState({ router: 'state' }, ''); + + await renderWithClerk(

Page

); + + await screen.findByRole('dialog'); + expect(window.location.search).toBe(''); + expect(window.history.state).toEqual({ router: 'state' }); + }); + it('ignores an unknown value', async () => { serve(); visit('?__clerk_mosaic_modal=organization-profile%2Fgeneral&tab=connections'); 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 index 8d49d3ce2ea..d3c54c52335 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts @@ -25,7 +25,7 @@ export function UserProfileModalReturn(): null { return; } url.searchParams.delete(MODAL_PARAM); - window.history.replaceState(null, '', url); + window.history.replaceState(window.history.state, '', url); open({ page: 'account' }); }, [open]); From a0bc0bd119198254aba4ac82ea8af4514c759eea Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 30 Oct 2026 11:58:25 -0400 Subject: [PATCH 3/4] fix(mosaic): drop the Next.js history marker when clearing the modal return param --- .../user-profile.modal-return.feature.test.tsx | 16 ++++++++++++++++ .../user-profile/user-profile.modal-return.ts | 9 ++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) 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 index 2d3bbb81b47..077d23012e5 100644 --- 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 @@ -6,6 +6,10 @@ import { serveFapi } from '../../../__tests__/feature/fake-fapi'; import { fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; +beforeAll(async () => { + await import('../user-profile.modal-content'); +}); + beforeAll(() => page.viewport(1280, 800)); function serve() { @@ -57,6 +61,18 @@ describe('UserProfile modal return', () => { expect(window.history.state).toEqual({ router: 'state' }); }); + it('drops the Next.js marker when it clears the param, so the App Router syncs the new URL', async () => { + serve(); + visit('?__clerk_mosaic_modal=user-profile%2Faccount'); + window.history.replaceState({ __NA: true, router: 'state' }, ''); + + await renderWithClerk(

Page

); + + await screen.findByRole('dialog'); + expect(window.location.search).toBe(''); + expect(window.history.state).toEqual({ router: 'state' }); + }); + it('ignores an unknown value', async () => { serve(); visit('?__clerk_mosaic_modal=organization-profile%2Fgeneral&tab=connections'); 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 index d3c54c52335..4d06e906a64 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts @@ -25,9 +25,16 @@ export function UserProfileModalReturn(): null { return; } url.searchParams.delete(MODAL_PARAM); - window.history.replaceState(window.history.state, '', url); + window.history.replaceState(withoutNextAppRouterMarker(window.history.state), '', url); open({ page: 'account' }); }, [open]); return null; } + +function withoutNextAppRouterMarker(state: unknown): unknown { + if (typeof state !== 'object' || state === null) { + return state; + } + return Object.fromEntries(Object.entries(state).filter(([key]) => key !== '__NA')); +} From 97bb65199bd29e36f6a6dbb7c6ab477263181f2a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 30 Oct 2026 11:58:26 -0400 Subject: [PATCH 4/4] test(mosaic): load modal content before feature tests that open a profile modal --- .../__tests__/organization-switcher.feature.test.tsx | 6 +++++- .../__tests__/user-button.new-profiles.feature.test.tsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx index 36ba0bc99fa..76dda54916d 100644 --- a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx +++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx @@ -1,6 +1,6 @@ import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest'; import { page } from 'vitest/browser'; import { openDialog } from '../../../__tests__/feature/dialog'; @@ -20,6 +20,10 @@ import { renderWithClerk } from '../../../__tests__/feature/render'; import type { OrganizationSwitcherProps } from '../organization-switcher'; import { OrganizationSwitcher } from '../organization-switcher'; +beforeAll(async () => { + await import('../../organization-profile/organization-profile.modal-content'); +}); + const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3 }); const other = fapiOrganization({ id: 'org_9', name: 'Other' }); const beta = fapiOrganization({ id: 'org_2', name: 'Beta' }); diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx index 5b49e03d37c..16f93c880c7 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx @@ -1,6 +1,6 @@ import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest'; import { page } from 'vitest/browser'; import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; @@ -21,6 +21,10 @@ import { signedIn as withConnectedAccounts } from '../../user-profile/user-profi import type { UserButtonProps } from '../user-button'; import { UserButton } from '../user-button'; +beforeAll(async () => { + await import('../../user-profile/user-profile.modal-content'); +}); + const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3 }); const other = fapiOrganization({ id: 'org_9', name: 'Other' }); const beta = fapiOrganization({ id: 'org_2', name: 'Beta' });