diff --git a/.changeset/custom-page-portal-stable-keys.md b/.changeset/custom-page-portal-stable-keys.md
new file mode 100644
index 00000000000..258c9320a0b
--- /dev/null
+++ b/.changeset/custom-page-portal-stable-keys.md
@@ -0,0 +1,5 @@
+---
+'@clerk/react': patch
+---
+
+Keep custom pages and menu items mounted when sibling pages are added, removed, or reordered. Portals are now keyed by a stable id rather than their array index, so a surviving page is reconciled as an update instead of being remounted.
diff --git a/.changeset/fix-org-profile-custom-page-remounts.md b/.changeset/fix-org-profile-custom-page-remounts.md
new file mode 100644
index 00000000000..07a51925d95
--- /dev/null
+++ b/.changeset/fix-org-profile-custom-page-remounts.md
@@ -0,0 +1,5 @@
+---
+'@clerk/react': patch
+---
+
+Prevent custom pages in profile components from remounting during parent rerenders.
diff --git a/integration/templates/react-vite/src/custom-user-profile/index.tsx b/integration/templates/react-vite/src/custom-user-profile/index.tsx
index f44e25f0e90..d1d94711aee 100644
--- a/integration/templates/react-vite/src/custom-user-profile/index.tsx
+++ b/integration/templates/react-vite/src/custom-user-profile/index.tsx
@@ -1,9 +1,12 @@
import { UserProfile } from '@clerk/react';
-import { useContext } from 'react';
+import { useContext, useState } from 'react';
import { PageContext, PageContextProvider } from '../PageContext.tsx';
function Page1() {
const { counter, setCounter } = useContext(PageContext);
+ // Local state lives INSIDE the portaled custom page. It only resets if the
+ // page is remounted, so it is our instrument for detecting remounts.
+ const [localCounter, setLocalCounter] = useState(0);
return (
<>
@@ -15,13 +18,31 @@ function Page1() {
>
Update
+
Local counter: {localCounter}
+
>
);
}
export default function Page() {
+ // Bumping parent state recreates the element, forcing the
+ // profile component (and useCustomPages) to rerender. The custom page content
+ // must survive this without remounting.
+ const [parentTick, setParentTick] = useState(0);
+
return (
+
Loading user profile>}
path={'/custom-user-profile'}
diff --git a/integration/tests/custom-pages.test.ts b/integration/tests/custom-pages.test.ts
index aa7892332f3..0636e964c71 100644
--- a/integration/tests/custom-pages.test.ts
+++ b/integration/tests/custom-pages.test.ts
@@ -171,6 +171,37 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })(
});
});
+ test('custom profile page survives a parent rerender without remounting', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.po.signIn.goTo();
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+
+ await u.page.goToRelative(CUSTOM_PROFILE_PAGE);
+ await u.po.userProfile.waitForMounted();
+
+ // Open the custom page (Page 1)
+ const [profilePage] = await u.page.locator('button.cl-navbarButton__custom-page-0').all();
+ await profilePage.click();
+
+ // Local state lives inside the portaled custom page and starts at 0.
+ await u.page.waitForSelector('p[data-local-counter="1"]', { state: 'attached' });
+ await expect(u.page.locator('p[data-local-counter="1"]')).toHaveText('Local counter: 0');
+
+ // Mutate the local state to 2.
+ await u.page.locator('button[data-local-counter="1"]').click();
+ await u.page.locator('button[data-local-counter="1"]').click();
+ await expect(u.page.locator('p[data-local-counter="1"]')).toHaveText('Local counter: 2');
+
+ // Force a parent rerender: this re-creates the element and reruns useCustomPages.
+ await u.page.locator('button[data-testid="rerender-parent"]').click();
+ await expect(u.page.locator('button[data-testid="rerender-parent"]')).toHaveText('Rerender parent: 1');
+
+ // The custom page must NOT remount, so its local state is preserved.
+ await expect(u.page.locator('p[data-local-counter="1"]')).toHaveText('Local counter: 2');
+ });
+
test.describe('User Button with experimental asStandalone and asProvider', () => {
test('items at the specified order', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
diff --git a/packages/react/src/components/ClerkHostRenderer.tsx b/packages/react/src/components/ClerkHostRenderer.tsx
index 3e1304caebc..886d7057a8d 100644
--- a/packages/react/src/components/ClerkHostRenderer.tsx
+++ b/packages/react/src/components/ClerkHostRenderer.tsx
@@ -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) ||
diff --git a/packages/react/src/components/__tests__/ClerkHostRenderer.test.tsx b/packages/react/src/components/__tests__/ClerkHostRenderer.test.tsx
new file mode 100644
index 00000000000..02f06a08237
--- /dev/null
+++ b/packages/react/src/components/__tests__/ClerkHostRenderer.test.tsx
@@ -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, ...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('', () => {
+ 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(
+ ,
+ );
+
+ expect(() =>
+ rerender(
+ ,
+ ),
+ ).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(
+ ,
+ );
+
+ rerender(
+ ,
+ );
+
+ expect(updateProps).toHaveBeenCalledTimes(1);
+ expect(updateProps).toHaveBeenCalledWith({
+ node: expect.any(HTMLDivElement),
+ props: { customPages: [{ label: 'General' }, { label: 'Permissions' }] },
+ });
+ });
+});
diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx
index da0ab7f8fbb..c4ca99c04f4 100644
--- a/packages/react/src/components/uiComponents.tsx
+++ b/packages/react/src/components/uiComponents.tsx
@@ -127,8 +127,8 @@ type OrganizationSwitcherPropsWithoutCustomPages = Without<
const CustomPortalsRenderer = (props: CustomPortalsRendererProps) => {
return (
<>
- {props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))}
- {props?.customMenuItemsPortals?.map((portal, index) => createElement(portal, { key: index }))}
+ {props?.customPagesPortals?.map(({ key, portal }) => createElement(portal, { key }))}
+ {props?.customMenuItemsPortals?.map(({ key, portal }) => createElement(portal, { key }))}
>
);
};
diff --git a/packages/react/src/utils/__tests__/customPages.remount.integration.test.tsx b/packages/react/src/utils/__tests__/customPages.remount.integration.test.tsx
new file mode 100644
index 00000000000..848a8b4d75d
--- /dev/null
+++ b/packages/react/src/utils/__tests__/customPages.remount.integration.test.tsx
@@ -0,0 +1,120 @@
+import { render, screen } from '@testing-library/react';
+import React, { createElement, useEffect, useRef } from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { OrganizationProfilePage } from '../../components/uiComponents';
+import { useOrganizationProfileCustomPages } from '../useCustomPages';
+
+vi.mock('@clerk/shared/utils', () => ({
+ logErrorInDevMode: vi.fn(),
+}));
+
+// Per-page mount/unmount counters. A remount re-runs the mount effect.
+const mounts: Record = {};
+const unmounts: Record = {};
+
+// Stable component type, defined once. If it remounts across a rerender it is
+// because the portal wrapping it changed identity or render key.
+const TrackedContent = ({ id, text }: { id: string; text: string }) => {
+ useEffect(() => {
+ mounts[id] = (mounts[id] ?? 0) + 1;
+ return () => {
+ unmounts[id] = (unmounts[id] ?? 0) + 1;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-once instrument; id is stable per instance
+ }, []);
+ return
{text}
;
+};
+
+/**
+ * Faithfully reproduces the production render path for custom pages:
+ * - useOrganizationProfileCustomPages parses children into { customPages, customPagesPortals }
+ * - clerk-js calls customPages[i].mount(node) once per logical page (by identity; here keyed by url)
+ * - CustomPortalsRenderer renders each portal via createElement(portal, { key }) using the STABLE key
+ */
+const Harness = ({ children, tick }: { children: React.ReactNode; tick: number }) => {
+ const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(children);
+ const hostRef = useRef(null);
+ const mountedUrls = useRef>(new Set());
+
+ useEffect(() => {
+ customPages.forEach(page => {
+ if (page.mount && page.url && !mountedUrls.current.has(page.url)) {
+ mountedUrls.current.add(page.url);
+ const node = document.createElement('div');
+ hostRef.current?.appendChild(node);
+ page.mount(node);
+ }
+ });
+ });
+
+ return (
+ <>
+
+ {customPagesPortals.map(({ key, portal }) => createElement(portal, { key }))}
+ >
+ );
+};
+
+const makePage = (id: string, label: string, url: string, text: string) => (
+ i}
+ url={url}
+ >
+
+
+);
+
+afterEach(() => {
+ for (const k of Object.keys(mounts)) {
+ delete mounts[k];
+ }
+ for (const k of Object.keys(unmounts)) {
+ delete unmounts[k];
+ }
+});
+
+describe('custom pages remount behavior (integration through CustomPortalsRenderer path)', () => {
+ it('does not remount custom page content when the parent rerenders', async () => {
+ const { rerender } = render({[makePage('p1', 'Page 1', 'page-1', 'first')]});
+
+ await screen.findByText('first');
+ expect(mounts['p1']).toBe(1);
+
+ // Parent rerenders for an unrelated reason; the page content prop changes but the
+ // logical page (key/label/url) is identical.
+ rerender({[makePage('p1', 'Page 1', 'page-1', 'second')]});
+
+ await screen.findByText('second');
+ expect(mounts['p1']).toBe(1);
+ expect(unmounts['p1'] ?? 0).toBe(0);
+ });
+
+ it('does not remount a surviving custom page when another page is inserted before it', async () => {
+ const second = makePage('second', 'Second', 'second', 'second-content');
+ const first = makePage('first', 'First', 'first', 'first-content');
+
+ const { rerender } = render({[second]});
+ await screen.findByText('second-content');
+ expect(mounts['second']).toBe(1);
+
+ // Insert a new page BEFORE the existing one.
+ rerender({[first, second]});
+ await screen.findByText('first-content');
+
+ // The surviving page keeps its stable key + portal identity, so React reconciles it as an
+ // update rather than a remount.
+ expect(mounts['second']).toBe(1);
+ expect(unmounts['second'] ?? 0).toBe(0);
+ // The newly inserted page mounts exactly once.
+ expect(mounts['first']).toBe(1);
+ });
+});
diff --git a/packages/react/src/utils/__tests__/useCustomElementPortal.test.tsx b/packages/react/src/utils/__tests__/useCustomElementPortal.test.tsx
new file mode 100644
index 00000000000..4e0bf2504c7
--- /dev/null
+++ b/packages/react/src/utils/__tests__/useCustomElementPortal.test.tsx
@@ -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