Skip to content

Commit 5f88603

Browse files
committed
fix(react): prevent custom page portal remounts
1 parent fe5a49c commit 5f88603

5 files changed

Lines changed: 175 additions & 18 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/react': patch
3+
---
4+
5+
Prevent custom pages in profile components from remounting during parent rerenders.

packages/react/src/components/ClerkHostRenderer.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,9 @@ export class ClerkHostRenderer extends React.PureComponent<
7979
const newProps = without(this.props.props, 'customPages', 'customMenuItems', 'children');
8080

8181
// instead, we simply use the length of customPages to determine if it changed or not
82-
const customPagesChanged = prevProps.customPages?.length !== newProps.customPages?.length;
83-
const customMenuItemsChanged = prevProps.customMenuItems?.length !== newProps.customMenuItems?.length;
82+
const customPagesChanged = _prevProps.props.customPages?.length !== this.props.props.customPages?.length;
83+
const customMenuItemsChanged =
84+
_prevProps.props.customMenuItems?.length !== this.props.props.customMenuItems?.length;
8485

8586
// Strip out mountIcon and unmountIcon handlers since they're always generated as new function references,
8687
// which would cause unnecessary re-renders in deep equality checks
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { render } from '@testing-library/react';
2+
import React from 'react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
5+
import { ClerkHostRenderer } from '../ClerkHostRenderer';
6+
7+
vi.mock('@clerk/shared/object', () => ({
8+
without: (obj: Record<string, unknown>, ...keys: string[]) =>
9+
Object.fromEntries(Object.entries(obj).filter(([key]) => !keys.includes(key))),
10+
}));
11+
12+
vi.mock('@clerk/shared/react', () => ({
13+
isDeeplyEqual: (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b),
14+
}));
15+
16+
describe('<ClerkHostRenderer />', () => {
17+
it('updates mounted component props when custom pages are added or removed', () => {
18+
const mount = vi.fn();
19+
const unmount = vi.fn();
20+
const updateProps = vi.fn();
21+
22+
const { rerender } = render(
23+
<ClerkHostRenderer
24+
mount={mount}
25+
props={{ customPages: [{ label: 'General' }] }}
26+
unmount={unmount}
27+
updateProps={updateProps}
28+
/>,
29+
);
30+
31+
rerender(
32+
<ClerkHostRenderer
33+
mount={mount}
34+
props={{ customPages: [{ label: 'General' }, { label: 'Permissions' }] }}
35+
unmount={unmount}
36+
updateProps={updateProps}
37+
/>,
38+
);
39+
40+
expect(updateProps).toHaveBeenCalledTimes(1);
41+
expect(updateProps).toHaveBeenCalledWith({
42+
node: expect.any(HTMLDivElement),
43+
props: { customPages: [{ label: 'General' }, { label: 'Permissions' }] },
44+
});
45+
});
46+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { render, screen } from '@testing-library/react';
2+
import React, { useEffect } from 'react';
3+
import { afterEach, describe, expect, it, vi } from 'vitest';
4+
5+
import { useCustomElementPortal } from '../useCustomElementPortal';
6+
7+
describe('useCustomElementPortal', () => {
8+
let portalRoot: HTMLDivElement;
9+
10+
afterEach(() => {
11+
portalRoot?.remove();
12+
});
13+
14+
it('does not remount portal content when the parent rerenders', async () => {
15+
const mountTracker = vi.fn();
16+
const unmountTracker = vi.fn();
17+
18+
const CustomContent = ({ label }: { label: string }) => {
19+
useEffect(() => {
20+
mountTracker();
21+
return unmountTracker;
22+
}, []);
23+
24+
return <div>{label}</div>;
25+
};
26+
27+
const TestComponent = ({ label }: { label: string }) => {
28+
const [{ mount, portal: Portal, unmount }] = useCustomElementPortal([
29+
{ component: <CustomContent label={label} />, id: 0 },
30+
]);
31+
32+
useEffect(() => {
33+
mount(portalRoot);
34+
return unmount;
35+
}, [mount, unmount]);
36+
37+
return <Portal />;
38+
};
39+
40+
portalRoot = document.createElement('div');
41+
document.body.appendChild(portalRoot);
42+
43+
const { rerender } = render(<TestComponent label='first render' />);
44+
45+
await screen.findByText('first render');
46+
expect(mountTracker).toHaveBeenCalledTimes(1);
47+
48+
rerender(<TestComponent label='second render' />);
49+
50+
await screen.findByText('second render');
51+
expect(mountTracker).toHaveBeenCalledTimes(1);
52+
expect(unmountTracker).not.toHaveBeenCalled();
53+
54+
expect(portalRoot.textContent).toBe('second render');
55+
});
56+
57+
it('renders falsy ReactNode values after mounting', async () => {
58+
const TestComponent = () => {
59+
const [{ mount, portal: Portal, unmount }] = useCustomElementPortal([{ component: 0, id: 0 }]);
60+
61+
useEffect(() => {
62+
mount(portalRoot);
63+
return unmount;
64+
}, [mount, unmount]);
65+
66+
return <Portal />;
67+
};
68+
69+
portalRoot = document.createElement('div');
70+
document.body.appendChild(portalRoot);
71+
72+
render(<TestComponent />);
73+
74+
await screen.findByText('0');
75+
expect(portalRoot.textContent).toBe('0');
76+
});
77+
});
Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type React from 'react';
2-
import { useState } from 'react';
2+
import { useRef, useState } from 'react';
33
import { createPortal } from 'react-dom';
44

55
export type UseCustomElementPortalParams = {
@@ -8,7 +8,7 @@ export type UseCustomElementPortalParams = {
88
};
99

1010
export type UseCustomElementPortalReturn = {
11-
portal: () => React.JSX.Element;
11+
portal: React.ComponentType;
1212
mount: (node: Element) => void;
1313
unmount: () => void;
1414
id: number;
@@ -18,19 +18,47 @@ export type UseCustomElementPortalReturn = {
1818
// the given component into a given node
1919
export const useCustomElementPortal = (elements: UseCustomElementPortalParams[]) => {
2020
const [nodeMap, setNodeMap] = useState<Map<string, Element | null>>(new Map());
21+
const nodeMapRef = useRef(nodeMap);
22+
const elementsRef = useRef<Map<string, React.ReactNode>>(new Map());
23+
const portalsRef = useRef<Map<string, UseCustomElementPortalReturn>>(new Map());
2124

22-
return elements.map(el => ({
23-
id: el.id,
24-
mount: (node: Element) => setNodeMap(prev => new Map(prev).set(String(el.id), node)),
25-
unmount: () =>
26-
setNodeMap(prev => {
27-
const newMap = new Map(prev);
28-
newMap.set(String(el.id), null);
29-
return newMap;
30-
}),
31-
portal: () => {
32-
const node = nodeMap.get(String(el.id));
33-
return node ? createPortal(el.component, node) : null;
34-
},
35-
}));
25+
nodeMapRef.current = nodeMap;
26+
elementsRef.current = new Map(elements.map(el => [String(el.id), el.component]));
27+
28+
const elementIds = new Set(elements.map(el => String(el.id)));
29+
portalsRef.current.forEach((_, id) => {
30+
if (!elementIds.has(id)) {
31+
portalsRef.current.delete(id);
32+
}
33+
});
34+
35+
return elements.map(el => {
36+
const id = String(el.id);
37+
const existingPortal = portalsRef.current.get(id);
38+
39+
if (existingPortal) {
40+
return existingPortal;
41+
}
42+
43+
const portal: React.ComponentType = () => {
44+
const node = nodeMapRef.current.get(id);
45+
const component = elementsRef.current.get(id);
46+
return node ? createPortal(component, node) : null;
47+
};
48+
49+
const customElementPortal = {
50+
id: el.id,
51+
mount: (node: Element) => setNodeMap(prev => new Map(prev).set(id, node)),
52+
unmount: () =>
53+
setNodeMap(prev => {
54+
const newMap = new Map(prev);
55+
newMap.set(id, null);
56+
return newMap;
57+
}),
58+
portal,
59+
};
60+
61+
portalsRef.current.set(id, customElementPortal);
62+
return customElementPortal;
63+
});
3664
};

0 commit comments

Comments
 (0)