Skip to content

Commit f82b0ab

Browse files
committed
Address custom portal review edge cases
1 parent 7a56740 commit f82b0ab

4 files changed

Lines changed: 80 additions & 12 deletions

File tree

packages/react/src/components/ClerkHostRenderer.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,18 +75,18 @@ export class ClerkHostRenderer extends React.PureComponent<
7575
// Remove children and customPages from props before comparing
7676
// children might hold circular references which deepEqual can't handle
7777
// and the implementation of customPages relies on props getting new references
78-
const prevProps = without(_prevProps.props, 'customPages', 'customMenuItems', 'children');
79-
const newProps = without(this.props.props, 'customPages', 'customMenuItems', 'children');
78+
const prevProps = without(_prevProps.props || {}, 'customPages', 'customMenuItems', 'children');
79+
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.props.customPages?.length !== this.props.props.customPages?.length;
82+
const customPagesChanged = _prevProps.props?.customPages?.length !== this.props.props?.customPages?.length;
8383
const customMenuItemsChanged =
84-
_prevProps.props.customMenuItems?.length !== this.props.props.customMenuItems?.length;
84+
_prevProps.props?.customMenuItems?.length !== this.props.props?.customMenuItems?.length;
8585

8686
// Strip out mountIcon and unmountIcon handlers since they're always generated as new function references,
8787
// which would cause unnecessary re-renders in deep equality checks
88-
const prevMenuItemsWithoutHandlers = stripMenuItemIconHandlers(_prevProps.props.customMenuItems);
89-
const newMenuItemsWithoutHandlers = stripMenuItemIconHandlers(this.props.props.customMenuItems);
88+
const prevMenuItemsWithoutHandlers = stripMenuItemIconHandlers(_prevProps.props?.customMenuItems);
89+
const newMenuItemsWithoutHandlers = stripMenuItemIconHandlers(this.props.props?.customMenuItems);
9090

9191
if (
9292
!isDeeplyEqual(prevProps, newProps) ||

packages/react/src/components/__tests__/ClerkHostRenderer.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,34 @@ vi.mock('@clerk/shared/react', () => ({
1414
}));
1515

1616
describe('<ClerkHostRenderer />', () => {
17+
it('does not throw when mounted component props are omitted during updates', () => {
18+
const mount = vi.fn();
19+
const unmount = vi.fn();
20+
const updateProps = vi.fn();
21+
22+
const { rerender } = render(
23+
<ClerkHostRenderer
24+
component='UserProfile'
25+
mount={mount}
26+
unmount={unmount}
27+
updateProps={updateProps}
28+
/>,
29+
);
30+
31+
expect(() =>
32+
rerender(
33+
<ClerkHostRenderer
34+
component='OrganizationProfile'
35+
mount={mount}
36+
unmount={unmount}
37+
updateProps={updateProps}
38+
/>,
39+
),
40+
).not.toThrow();
41+
42+
expect(updateProps).not.toHaveBeenCalled();
43+
});
44+
1745
it('updates mounted component props when custom pages are added or removed', () => {
1846
const mount = vi.fn();
1947
const unmount = vi.fn();

packages/react/src/utils/__tests__/useCustomElementPortal.test.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,42 @@ describe('useCustomElementPortal', () => {
7474
await screen.findByText('0');
7575
expect(portalRoot.textContent).toBe('0');
7676
});
77+
78+
it('keeps string and number ids in separate portal caches', async () => {
79+
const TestComponent = () => {
80+
const [
81+
{ mount: mountNumber, portal: NumberPortal, unmount: unmountNumber },
82+
{ mount: mountString, portal: StringPortal, unmount: unmountString },
83+
] = useCustomElementPortal([
84+
{ component: <div>number id</div>, id: 1 },
85+
{ component: <div>string id</div>, id: '1' },
86+
]);
87+
88+
useEffect(() => {
89+
mountNumber(portalRoot);
90+
mountString(portalRoot);
91+
92+
return () => {
93+
unmountNumber();
94+
unmountString();
95+
};
96+
}, [mountNumber, mountString, unmountNumber, unmountString]);
97+
98+
return (
99+
<>
100+
<NumberPortal />
101+
<StringPortal />
102+
</>
103+
);
104+
};
105+
106+
portalRoot = document.createElement('div');
107+
document.body.appendChild(portalRoot);
108+
109+
render(<TestComponent />);
110+
111+
await screen.findByText('number id');
112+
await screen.findByText('string id');
113+
expect(portalRoot.textContent).toBe('number idstring id');
114+
});
77115
});

packages/react/src/utils/useCustomElementPortal.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,28 @@ export type UseCustomElementPortalReturn = {
1414
id: string | number;
1515
};
1616

17+
type PortalKey = string | number;
18+
1719
// This function takes a component as prop, and returns functions that mount and unmount
1820
// the given component into a given node
1921
export const useCustomElementPortal = (elements: UseCustomElementPortalParams[]) => {
20-
const [nodeMap, setNodeMap] = useState<Map<string, Element | null>>(new Map());
22+
const [nodeMap, setNodeMap] = useState<Map<PortalKey, Element | null>>(new Map());
2123
const nodeMapRef = useRef(nodeMap);
22-
const elementsRef = useRef<Map<string, React.ReactNode>>(new Map());
23-
const portalsRef = useRef<Map<string, UseCustomElementPortalReturn>>(new Map());
24+
const elementsRef = useRef<Map<PortalKey, React.ReactNode>>(new Map());
25+
const portalsRef = useRef<Map<PortalKey, UseCustomElementPortalReturn>>(new Map());
2426

2527
nodeMapRef.current = nodeMap;
26-
elementsRef.current = new Map(elements.map(el => [String(el.id), el.component]));
28+
elementsRef.current = new Map(elements.map(el => [el.id, el.component]));
2729

28-
const elementIds = new Set(elements.map(el => String(el.id)));
30+
const elementIds = new Set(elements.map(el => el.id));
2931
portalsRef.current.forEach((_, id) => {
3032
if (!elementIds.has(id)) {
3133
portalsRef.current.delete(id);
3234
}
3335
});
3436

3537
return elements.map(el => {
36-
const id = String(el.id);
38+
const id = el.id;
3739
const existingPortal = portalsRef.current.get(id);
3840

3941
if (existingPortal) {

0 commit comments

Comments
 (0)