Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-org-profile-custom-page-remounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/react': patch
---

Prevent custom pages in profile components from remounting during parent rerenders.
13 changes: 7 additions & 6 deletions packages/react/src/components/ClerkHostRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,18 @@ export class ClerkHostRenderer extends React.PureComponent<
// Remove children and customPages from props before comparing
// children might hold circular references which deepEqual can't handle
// and the implementation of customPages relies on props getting new references
const prevProps = without(_prevProps.props, 'customPages', 'customMenuItems', 'children');
const newProps = without(this.props.props, 'customPages', 'customMenuItems', 'children');
const prevProps = without(_prevProps.props || {}, 'customPages', 'customMenuItems', 'children');
const newProps = without(this.props.props || {}, 'customPages', 'customMenuItems', 'children');

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

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

if (
!isDeeplyEqual(prevProps, newProps) ||
Expand Down
74 changes: 74 additions & 0 deletions packages/react/src/components/__tests__/ClerkHostRenderer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { render } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';

import { ClerkHostRenderer } from '../ClerkHostRenderer';

vi.mock('@clerk/shared/object', () => ({
without: (obj: Record<string, unknown>, ...keys: string[]) =>
Object.fromEntries(Object.entries(obj).filter(([key]) => !keys.includes(key))),
}));

vi.mock('@clerk/shared/react', () => ({
isDeeplyEqual: (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b),
}));

describe('<ClerkHostRenderer />', () => {
it('does not throw when mounted component props are omitted during updates', () => {
const mount = vi.fn();
const unmount = vi.fn();
const updateProps = vi.fn();

const { rerender } = render(
<ClerkHostRenderer
component='UserProfile'
mount={mount}
unmount={unmount}
updateProps={updateProps}
/>,
);

expect(() =>
rerender(
<ClerkHostRenderer
component='OrganizationProfile'
mount={mount}
unmount={unmount}
updateProps={updateProps}
/>,
),
).not.toThrow();

expect(updateProps).not.toHaveBeenCalled();
});

it('updates mounted component props when custom pages are added or removed', () => {
const mount = vi.fn();
const unmount = vi.fn();
const updateProps = vi.fn();

const { rerender } = render(
<ClerkHostRenderer
mount={mount}
props={{ customPages: [{ label: 'General' }] }}
unmount={unmount}
updateProps={updateProps}
/>,
);

rerender(
<ClerkHostRenderer
mount={mount}
props={{ customPages: [{ label: 'General' }, { label: 'Permissions' }] }}
unmount={unmount}
updateProps={updateProps}
/>,
);

expect(updateProps).toHaveBeenCalledTimes(1);
expect(updateProps).toHaveBeenCalledWith({
node: expect.any(HTMLDivElement),
props: { customPages: [{ label: 'General' }, { label: 'Permissions' }] },
});
});
});
115 changes: 115 additions & 0 deletions packages/react/src/utils/__tests__/useCustomElementPortal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { render, screen } from '@testing-library/react';
import React, { useEffect } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { useCustomElementPortal } from '../useCustomElementPortal';

describe('useCustomElementPortal', () => {
let portalRoot: HTMLDivElement;

afterEach(() => {
portalRoot?.remove();
});

it('does not remount portal content when the parent rerenders', async () => {
const mountTracker = vi.fn();
const unmountTracker = vi.fn();

const CustomContent = ({ label }: { label: string }) => {
useEffect(() => {
mountTracker();
return unmountTracker;
}, []);

return <div>{label}</div>;
};

const TestComponent = ({ label }: { label: string }) => {
const [{ mount, portal: Portal, unmount }] = useCustomElementPortal([
{ component: <CustomContent label={label} />, id: 0 },
]);

useEffect(() => {
mount(portalRoot);
return unmount;
}, [mount, unmount]);

return <Portal />;
};

portalRoot = document.createElement('div');
document.body.appendChild(portalRoot);

const { rerender } = render(<TestComponent label='first render' />);

await screen.findByText('first render');
expect(mountTracker).toHaveBeenCalledTimes(1);

rerender(<TestComponent label='second render' />);

await screen.findByText('second render');
expect(mountTracker).toHaveBeenCalledTimes(1);
expect(unmountTracker).not.toHaveBeenCalled();

expect(portalRoot.textContent).toBe('second render');
});

it('renders falsy ReactNode values after mounting', async () => {
const TestComponent = () => {
const [{ mount, portal: Portal, unmount }] = useCustomElementPortal([{ component: 0, id: 0 }]);

useEffect(() => {
mount(portalRoot);
return unmount;
}, [mount, unmount]);

return <Portal />;
};

portalRoot = document.createElement('div');
document.body.appendChild(portalRoot);

render(<TestComponent />);

await screen.findByText('0');
expect(portalRoot.textContent).toBe('0');
});

it('keeps string and number ids in separate portal caches', async () => {
const TestComponent = () => {
const [
{ mount: mountNumber, portal: NumberPortal, unmount: unmountNumber },
{ mount: mountString, portal: StringPortal, unmount: unmountString },
] = useCustomElementPortal([
{ component: <div>number id</div>, id: 1 },
{ component: <div>string id</div>, id: '1' },
]);

useEffect(() => {
mountNumber(portalRoot);
mountString(portalRoot);

return () => {
unmountNumber();
unmountString();
};
}, [mountNumber, mountString, unmountNumber, unmountString]);

return (
<>
<NumberPortal />
<StringPortal />
</>
);
};

portalRoot = document.createElement('div');
document.body.appendChild(portalRoot);

render(<TestComponent />);

await screen.findByText('number id');
await screen.findByText('string id');
expect(portalRoot.textContent).toBe('number idstring id');
});
});
60 changes: 60 additions & 0 deletions packages/react/src/utils/__tests__/useCustomMenuItems.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,64 @@ describe('useUserButtonCustomMenuItems', () => {
expect(mockOnClick).toHaveBeenCalledTimes(1);
}
});

it('uses separate portals for duplicate non-keyed menu items', () => {
const children = (
<MenuItems>
<MenuAction
label='Duplicate'
labelIcon={<div>First icon</div>}
open='/duplicate'
/>
<MenuAction
label='Duplicate'
labelIcon={<div>Second icon</div>}
open='/duplicate'
/>
</MenuItems>
);

const { result } = renderHook(() => useUserButtonCustomMenuItems(children));

expect(result.current.customMenuItems).toHaveLength(2);
expect(result.current.customMenuItemsPortals).toHaveLength(2);
expect(result.current.customMenuItemsPortals[0]).not.toBe(result.current.customMenuItemsPortals[1]);
});

it('keeps portal identity with the logical menu item when inserting before it', () => {
const firstItem = (
<MenuAction
key='first'
label='First action'
labelIcon={<div>First icon</div>}
onClick={() => {}}
/>
);
const secondItem = (
<MenuAction
key='second'
label='Second action'
labelIcon={<div>Second icon</div>}
onClick={() => {}}
/>
);
const makeChildren = (includeFirstItem: boolean) => (
<MenuItems>{includeFirstItem ? [firstItem, secondItem] : [secondItem]}</MenuItems>
);

const { result, rerender } = renderHook(
({ includeFirstItem }) => useUserButtonCustomMenuItems(makeChildren(includeFirstItem)),
{
initialProps: { includeFirstItem: false },
},
);

const secondItemIconPortal = result.current.customMenuItemsPortals[0];

rerender({ includeFirstItem: true });

expect(result.current.customMenuItems).toHaveLength(2);
expect(result.current.customMenuItemsPortals[0]).not.toBe(secondItemIconPortal);
expect(result.current.customMenuItemsPortals[1]).toBe(secondItemIconPortal);
});
});
84 changes: 84 additions & 0 deletions packages/react/src/utils/__tests__/useCustomPages.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { renderHook } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';

import { OrganizationProfilePage } from '../../components/uiComponents';
import { useOrganizationProfileCustomPages } from '../useCustomPages';

vi.mock('@clerk/shared', () => ({
logErrorInDevMode: vi.fn(),
}));

describe('useOrganizationProfileCustomPages', () => {
it('uses separate portals for duplicate non-keyed custom pages', () => {
const children = [
React.createElement(
OrganizationProfilePage,
{
label: 'Duplicate',
labelIcon: <div>First icon</div>,
url: 'duplicate',
},
<div>First content</div>,
),
React.createElement(
OrganizationProfilePage,
{
label: 'Duplicate',
labelIcon: <div>Second icon</div>,
url: 'duplicate',
},
<div>Second content</div>,
),
];

const { result } = renderHook(() => useOrganizationProfileCustomPages(children));

expect(result.current.customPages).toHaveLength(2);
expect(result.current.customPagesPortals).toHaveLength(4);
expect(result.current.customPagesPortals[0]).not.toBe(result.current.customPagesPortals[2]);
expect(result.current.customPagesPortals[1]).not.toBe(result.current.customPagesPortals[3]);
});

it('keeps portal identity with the logical custom page when inserting before it', () => {
const firstPage = (
<OrganizationProfilePage
key='first'
label='First page'
labelIcon={<div>First icon</div>}
url='first'
>
<div>First content</div>
</OrganizationProfilePage>
);
const secondPage = (
<OrganizationProfilePage
key='second'
label='Second page'
labelIcon={<div>Second icon</div>}
url='second'
>
<div>Second content</div>
</OrganizationProfilePage>
);
const makeChildren = (includeFirstPage: boolean) => (includeFirstPage ? [firstPage, secondPage] : [secondPage]);

const { result, rerender } = renderHook(
({ includeFirstPage }) => useOrganizationProfileCustomPages(makeChildren(includeFirstPage)),
{
initialProps: { includeFirstPage: false },
},
);

const secondPageContentPortal = result.current.customPagesPortals[0];
const secondPageIconPortal = result.current.customPagesPortals[1];

rerender({ includeFirstPage: true });

expect(result.current.customPages).toHaveLength(2);
expect(result.current.customPagesPortals[0]).not.toBe(secondPageContentPortal);
expect(result.current.customPagesPortals[1]).not.toBe(secondPageIconPortal);
expect(result.current.customPagesPortals[2]).toBe(secondPageContentPortal);
expect(result.current.customPagesPortals[3]).toBe(secondPageIconPortal);
});
});
Loading
Loading