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