diff --git a/.changeset/mosaic-modal-return-history.md b/.changeset/mosaic-modal-return-history.md new file mode 100644 index 00000000000..d5dfc8f8d96 --- /dev/null +++ b/.changeset/mosaic-modal-return-history.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Fix the user profile modal's return from a redirect on Next.js App Router apps: the URL change now reaches the router, so `useSearchParams` no longer keeps the removed param and going back no longer reloads the page. History state that isn't a plain object is also kept intact. 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 077d23012e5..a296493d559 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 @@ -1,10 +1,14 @@ -import { configure, screen, within } from '@testing-library/react'; -import { beforeAll, describe, expect, it, onTestFinished } from 'vitest'; +import { Clerk } from '@clerk/clerk-js'; +import { ClerkContextProvider } from '@clerk/shared/react'; +import { act, configure, render, screen, within } from '@testing-library/react'; +import React from 'react'; +import { beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest'; import { page } from 'vitest/browser'; -import { serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { publishableKey, serveFapi } from '../../../__tests__/feature/fake-fapi'; import { fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; +import { MosaicProvider } from '../../../mosaic-provider'; beforeAll(async () => { await import('../user-profile.modal-content'); @@ -30,6 +34,26 @@ function strictMode() { onTestFinished(() => configure({ reactStrictMode: false })); } +function PatchesHistoryOnMount({ + onReplaceState, + children, +}: { + onReplaceState: (url: string) => void; + children: React.ReactNode; +}) { + React.useEffect(() => { + const original = window.history.replaceState.bind(window.history); + window.history.replaceState = (state, unused, url) => { + onReplaceState(String(url)); + original(state, unused, url); + }; + return () => { + window.history.replaceState = original; + }; + }, [onReplaceState]); + return children; +} + describe('UserProfile modal return', () => { it.each([ { name: 'renders normally', strict: false }, @@ -73,6 +97,59 @@ describe('UserProfile modal return', () => { expect(window.history.state).toEqual({ router: 'state' }); }); + it('clears the param through a history patch that a parent installs while mounting', async () => { + serve(); + visit('?__clerk_mosaic_modal=user-profile%2Faccount'); + const onReplaceState = vi.fn(); + const clerk = new Clerk(publishableKey()); + + const tree = () => ( + + + +

Page

+
+
+
+ ); + const view = render(tree()); + await act(() => clerk.load()); + view.rerender(tree()); + + await screen.findByRole('dialog'); + expect(window.location.search).toBe(''); + expect(onReplaceState).toHaveBeenCalledTimes(1); + }); + + it('keeps history state that is not a plain object 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('keeps an array as an array when it drops the Next.js marker', async () => { + serve(); + visit('?__clerk_mosaic_modal=user-profile%2Faccount'); + window.history.replaceState(Object.assign(['router', 'state'], { __NA: true }), ''); + + await renderWithClerk(

Page

); + + await screen.findByRole('dialog'); + expect(window.location.search).toBe(''); + expect(Array.isArray(window.history.state)).toBe(true); + expect(window.history.state).toEqual(['router', 'state']); + expect(window.history.state).not.toHaveProperty('__NA'); + }); + 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 4d06e906a64..01bb0db3647 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 @@ -20,21 +20,25 @@ 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(withoutNextAppRouterMarker(window.history.state), '', url); - open({ page: 'account' }); + queueMicrotask(() => { + const url = new URL(window.location.href); + if (url.searchParams.get(MODAL_PARAM) !== ACCOUNT_PAGE) { + return; + } + url.searchParams.delete(MODAL_PARAM); + 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) { + if (typeof state !== 'object' || state === null || !('__NA' in state)) { return state; } - return Object.fromEntries(Object.entries(state).filter(([key]) => key !== '__NA')); + const copy: object = Object.assign(Array.isArray(state) ? [] : {}, state); + Reflect.deleteProperty(copy, '__NA'); + return copy; }