Skip to content

Commit 8619030

Browse files
fix(mosaic): clear the modal return param after the Next.js history patch is installed (#10550)
1 parent 9f0874e commit 8619030

3 files changed

Lines changed: 98 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/mosaic': patch
3+
---
4+
5+
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.

‎packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx‎

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
import { configure, screen, within } from '@testing-library/react';
2-
import { beforeAll, describe, expect, it, onTestFinished } from 'vitest';
1+
import { Clerk } from '@clerk/clerk-js';
2+
import { ClerkContextProvider } from '@clerk/shared/react';
3+
import { act, configure, render, screen, within } from '@testing-library/react';
4+
import React from 'react';
5+
import { beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest';
36
import { page } from 'vitest/browser';
47

5-
import { serveFapi } from '../../../__tests__/feature/fake-fapi';
8+
import { publishableKey, serveFapi } from '../../../__tests__/feature/fake-fapi';
69
import { fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
710
import { renderWithClerk } from '../../../__tests__/feature/render';
11+
import { MosaicProvider } from '../../../mosaic-provider';
812

913
beforeAll(async () => {
1014
await import('../user-profile.modal-content');
@@ -30,6 +34,26 @@ function strictMode() {
3034
onTestFinished(() => configure({ reactStrictMode: false }));
3135
}
3236

37+
function PatchesHistoryOnMount({
38+
onReplaceState,
39+
children,
40+
}: {
41+
onReplaceState: (url: string) => void;
42+
children: React.ReactNode;
43+
}) {
44+
React.useEffect(() => {
45+
const original = window.history.replaceState.bind(window.history);
46+
window.history.replaceState = (state, unused, url) => {
47+
onReplaceState(String(url));
48+
original(state, unused, url);
49+
};
50+
return () => {
51+
window.history.replaceState = original;
52+
};
53+
}, [onReplaceState]);
54+
return children;
55+
}
56+
3357
describe('UserProfile modal return', () => {
3458
it.each([
3559
{ name: 'renders normally', strict: false },
@@ -73,6 +97,59 @@ describe('UserProfile modal return', () => {
7397
expect(window.history.state).toEqual({ router: 'state' });
7498
});
7599

100+
it('clears the param through a history patch that a parent installs while mounting', async () => {
101+
serve();
102+
visit('?__clerk_mosaic_modal=user-profile%2Faccount');
103+
const onReplaceState = vi.fn();
104+
const clerk = new Clerk(publishableKey());
105+
106+
const tree = () => (
107+
<PatchesHistoryOnMount onReplaceState={onReplaceState}>
108+
<ClerkContextProvider
109+
clerk={clerk}
110+
clerkStatus={clerk.status}
111+
>
112+
<MosaicProvider>
113+
<p>Page</p>
114+
</MosaicProvider>
115+
</ClerkContextProvider>
116+
</PatchesHistoryOnMount>
117+
);
118+
const view = render(tree());
119+
await act(() => clerk.load());
120+
view.rerender(tree());
121+
122+
await screen.findByRole('dialog');
123+
expect(window.location.search).toBe('');
124+
expect(onReplaceState).toHaveBeenCalledTimes(1);
125+
});
126+
127+
it('keeps history state that is not a plain object when it clears the param', async () => {
128+
serve();
129+
visit('?__clerk_mosaic_modal=user-profile%2Faccount');
130+
window.history.replaceState(['router', 'state'], '');
131+
132+
await renderWithClerk(<p>Page</p>);
133+
134+
await screen.findByRole('dialog');
135+
expect(window.location.search).toBe('');
136+
expect(window.history.state).toEqual(['router', 'state']);
137+
});
138+
139+
it('keeps an array as an array when it drops the Next.js marker', async () => {
140+
serve();
141+
visit('?__clerk_mosaic_modal=user-profile%2Faccount');
142+
window.history.replaceState(Object.assign(['router', 'state'], { __NA: true }), '');
143+
144+
await renderWithClerk(<p>Page</p>);
145+
146+
await screen.findByRole('dialog');
147+
expect(window.location.search).toBe('');
148+
expect(Array.isArray(window.history.state)).toBe(true);
149+
expect(window.history.state).toEqual(['router', 'state']);
150+
expect(window.history.state).not.toHaveProperty('__NA');
151+
});
152+
76153
it('ignores an unknown value', async () => {
77154
serve();
78155
visit('?__clerk_mosaic_modal=organization-profile%2Fgeneral&tab=connections');

‎packages/mosaic/src/features/user-profile/user-profile.modal-return.ts‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,25 @@ export function UserProfileModalReturn(): null {
2020
const { open } = useModal(userProfileModal);
2121

2222
React.useEffect(() => {
23-
const url = new URL(window.location.href);
24-
if (url.searchParams.get(MODAL_PARAM) !== ACCOUNT_PAGE) {
25-
return;
26-
}
27-
url.searchParams.delete(MODAL_PARAM);
28-
window.history.replaceState(withoutNextAppRouterMarker(window.history.state), '', url);
29-
open({ page: 'account' });
23+
queueMicrotask(() => {
24+
const url = new URL(window.location.href);
25+
if (url.searchParams.get(MODAL_PARAM) !== ACCOUNT_PAGE) {
26+
return;
27+
}
28+
url.searchParams.delete(MODAL_PARAM);
29+
window.history.replaceState(withoutNextAppRouterMarker(window.history.state), '', url);
30+
open({ page: 'account' });
31+
});
3032
}, [open]);
3133

3234
return null;
3335
}
3436

3537
function withoutNextAppRouterMarker(state: unknown): unknown {
36-
if (typeof state !== 'object' || state === null) {
38+
if (typeof state !== 'object' || state === null || !('__NA' in state)) {
3739
return state;
3840
}
39-
return Object.fromEntries(Object.entries(state).filter(([key]) => key !== '__NA'));
41+
const copy: object = Object.assign(Array.isArray(state) ? [] : {}, state);
42+
Reflect.deleteProperty(copy, '__NA');
43+
return copy;
4044
}

0 commit comments

Comments
 (0)