From 2535461060081bb2b78e99491c39196e9d4f9174 Mon Sep 17 00:00:00 2001
From: Alex Carpenter
Date: Wed, 5 Aug 2026 16:52:31 -0400
Subject: [PATCH 1/2] feat(ui): add custom UserProfile pages to the Mosaic
UserButton
---
.changeset/mosaic-user-button-custom-pages.md | 2 +
packages/nextjs/src/experimental/mosaic.ts | 9 +-
packages/react/src/experimental/mosaic.ts | 9 +-
.../hooks/__tests__/useCustomPages.test.tsx | 207 ++++++++++++++++++
.../ui/src/mosaic/hooks/useCustomPages.tsx | 143 ++++++++++++
.../src/mosaic/hooks/useUserProfilePages.ts | 33 +++
packages/ui/src/mosaic/index.ts | 4 +-
.../__tests__/user-button.controller.test.tsx | 25 ++-
.../user-button.integration.test.tsx | 47 +++-
.../user-button/user-button.controller.tsx | 14 +-
.../ui/src/mosaic/user-button/user-button.tsx | 90 +++++---
11 files changed, 546 insertions(+), 37 deletions(-)
create mode 100644 .changeset/mosaic-user-button-custom-pages.md
create mode 100644 packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx
create mode 100644 packages/ui/src/mosaic/hooks/useCustomPages.tsx
create mode 100644 packages/ui/src/mosaic/hooks/useUserProfilePages.ts
diff --git a/.changeset/mosaic-user-button-custom-pages.md b/.changeset/mosaic-user-button-custom-pages.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-user-button-custom-pages.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/nextjs/src/experimental/mosaic.ts b/packages/nextjs/src/experimental/mosaic.ts
index 27da5d32e99..1ef439172ce 100644
--- a/packages/nextjs/src/experimental/mosaic.ts
+++ b/packages/nextjs/src/experimental/mosaic.ts
@@ -13,4 +13,11 @@
* @experimental The surface and the components behind it are subject to change.
*/
export { UserButton } from '@clerk/react/experimental/mosaic';
-export type { UserButtonProps } from '@clerk/react/experimental/mosaic';
+export type {
+ CustomProfileItem,
+ CustomProfileLink,
+ CustomProfilePage,
+ UserButtonProps,
+ UserButtonUserProfileProps,
+ UserProfilePageId,
+} from '@clerk/react/experimental/mosaic';
diff --git a/packages/react/src/experimental/mosaic.ts b/packages/react/src/experimental/mosaic.ts
index 6ffe79f4533..fd6015c5aef 100644
--- a/packages/react/src/experimental/mosaic.ts
+++ b/packages/react/src/experimental/mosaic.ts
@@ -11,4 +11,11 @@
* @experimental The surface and the components behind it are subject to change.
*/
export { UserButton } from '@clerk/ui/experimental/mosaic';
-export type { UserButtonProps } from '@clerk/ui/experimental/mosaic';
+export type {
+ CustomProfileItem,
+ CustomProfileLink,
+ CustomProfilePage,
+ UserButtonProps,
+ UserButtonUserProfileProps,
+ UserProfilePageId,
+} from '@clerk/ui/experimental/mosaic';
diff --git a/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx b/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx
new file mode 100644
index 00000000000..e3987d293bb
--- /dev/null
+++ b/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx
@@ -0,0 +1,207 @@
+import type { CustomPage } from '@clerk/shared/types';
+import { act, render, screen, within } from '@testing-library/react';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import type { CustomPagesOptions, CustomProfileItem } from '../useCustomPages';
+import { useCustomPages } from '../useCustomPages';
+
+// The bridge's other half lives in clerk-js: `ExternalElementMounter` renders a `div` and hands it to
+// `mount`, then hands it back to `unmount` when the profile goes away. These stand in for it, so the
+// tests exercise the same handshake the real modal performs.
+function mountInto(callback: ((el: HTMLDivElement) => void) | undefined): HTMLDivElement {
+ const el = document.createElement('div');
+ document.body.appendChild(el);
+ act(() => callback?.(el));
+ return el;
+}
+
+function unmountFrom(callback: ((el?: HTMLDivElement) => void) | undefined, el: HTMLDivElement) {
+ act(() => callback?.(el));
+ el.remove();
+}
+
+let emitted: CustomPage[] | undefined;
+
+function Harness({ items, order, builtInPages = ['account', 'security'] }: Partial) {
+ const { customPages, portals } = useCustomPages({ items, order, builtInPages });
+ emitted = customPages;
+ return {portals}
;
+}
+
+const terms: CustomProfileItem = {
+ label: 'Terms',
+ path: 'terms',
+ icon: terms icon,
+ content: Terms body
,
+};
+
+const docs: CustomProfileItem = { label: 'Docs', href: 'https://clerk.com/docs', icon: docs icon };
+
+beforeEach(() => {
+ emitted = undefined;
+});
+
+describe('useCustomPages', () => {
+ it('sends nothing when there are no custom pages', () => {
+ render();
+
+ expect(emitted).toBeUndefined();
+ expect(screen.getByTestId('host')).toBeEmptyDOMElement();
+ });
+
+ it('sends a page as its path and a link as its href', () => {
+ render();
+
+ expect(emitted?.map(page => page.url)).toEqual(['terms', 'https://clerk.com/docs']);
+ expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']);
+ });
+
+ // clerk-js tells a page from a link by which callbacks are present, so content callbacks are what
+ // make an item a page. A link carrying them would be routed to instead of followed.
+ it('sends content callbacks for a page and none for a link', () => {
+ render();
+
+ const [page, link] = emitted ?? [];
+ expect(page.mount).toBeTypeOf('function');
+ expect(page.unmount).toBeTypeOf('function');
+ expect(link.mount).toBeUndefined();
+ expect(link.unmount).toBeUndefined();
+ });
+
+ // The same presence check rejects any item missing an icon pair outright, so the callbacks go out
+ // whether or not there is an icon to put through them. Without this, `icon` could not be optional:
+ // leaving it off would drop the page from the profile with no explanation.
+ it('sends the icon callbacks even for an item with no icon', () => {
+ render(Terms body
}]} />);
+
+ const [page] = emitted ?? [];
+ expect(page.mountIcon).toBeTypeOf('function');
+ expect(page.unmountIcon).toBeTypeOf('function');
+
+ const el = mountInto(page.mountIcon);
+ expect(el).toBeEmptyDOMElement();
+ });
+
+ it('renders page content into the element clerk-js hands back', () => {
+ render();
+
+ const el = mountInto(emitted?.[0].mount);
+
+ expect(within(el).getByText('Terms body')).toBeInTheDocument();
+ });
+
+ it('renders an icon into its own element, apart from the content', () => {
+ render();
+
+ const content = mountInto(emitted?.[0].mount);
+ const icon = mountInto(emitted?.[0].mountIcon);
+
+ expect(within(icon).getByText('terms icon')).toBeInTheDocument();
+ expect(within(content).queryByText('terms icon')).toBeNull();
+ });
+
+ it('keeps each page in the element that asked for it', () => {
+ const help: CustomProfileItem = { label: 'Help', path: 'help', content: Help body
};
+ render();
+
+ const first = mountInto(emitted?.[0].mount);
+ const second = mountInto(emitted?.[1].mount);
+
+ expect(within(first).getByText('Terms body')).toBeInTheDocument();
+ expect(within(second).getByText('Help body')).toBeInTheDocument();
+ });
+
+ it('stops rendering content once clerk-js gives the element back', () => {
+ render();
+
+ const el = mountInto(emitted?.[0].mount);
+ expect(within(el).getByText('Terms body')).toBeInTheDocument();
+
+ unmountFrom(emitted?.[0].unmount, el);
+
+ expect(screen.queryByText('Terms body')).toBeNull();
+ });
+
+ // The profile is opened once with the callbacks from that render, and never handed a later set.
+ // They have to keep working against the current content, or a page re-rendered while the profile
+ // is open goes stale.
+ it('renders updated content through the callbacks the profile was opened with', () => {
+ const { rerender } = render();
+ const el = mountInto(emitted?.[0].mount);
+
+ rerender(Revised terms }]} />);
+
+ expect(within(el).getByText('Revised terms')).toBeInTheDocument();
+ });
+
+ describe('order', () => {
+ it('leaves the built-in pages alone when no order is given', () => {
+ render();
+
+ expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']);
+ });
+
+ it('sends the pages in the order it was given', () => {
+ render(
+ ,
+ );
+
+ expect(emitted?.map(page => page.label)).toEqual(['security', 'Terms', 'account', 'Docs']);
+ });
+
+ // clerk-js takes a request to move a built-in page as the page's id and nothing else; anything
+ // more and it reads as a custom page instead.
+ it('sends a built-in page as its id alone', () => {
+ render();
+
+ expect(emitted).toEqual([{ label: 'security' }, { label: 'account' }]);
+ });
+
+ // clerk-js puts a built-in page it was not sent *before* every page it was, so leaving one out
+ // of the order would jump it to the front rather than leave it where it was.
+ it('sends the pages left out of the order after the ones in it', () => {
+ render(
+ ,
+ );
+
+ expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security', 'billing', 'Docs']);
+ });
+
+ it('drops an id that belongs to no page', () => {
+ render(
+ ,
+ );
+
+ expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security']);
+ });
+
+ it('sends a page once even when the order names it twice', () => {
+ render();
+
+ expect(emitted?.map(page => page.label)).toEqual(['security', 'account']);
+ });
+
+ it('renders a reordered page into the element clerk-js hands back', () => {
+ render(
+ ,
+ );
+
+ const el = mountInto(emitted?.[1].mount);
+
+ expect(within(el).getByText('Terms body')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/ui/src/mosaic/hooks/useCustomPages.tsx b/packages/ui/src/mosaic/hooks/useCustomPages.tsx
new file mode 100644
index 00000000000..87e916b9b9d
--- /dev/null
+++ b/packages/ui/src/mosaic/hooks/useCustomPages.tsx
@@ -0,0 +1,143 @@
+import type { CustomPage } from '@clerk/shared/types';
+import type { ReactNode } from 'react';
+import { useCallback, useState } from 'react';
+import { createPortal } from 'react-dom';
+
+/** A page of your own inside the profile, reached from its navigation. */
+export interface CustomProfilePage {
+ /** Names the page in the profile's navigation. */
+ label: string;
+ /** Where the page lives, relative to the profile root. Absolute URLs are rejected. */
+ path: string;
+ href?: never;
+ icon?: ReactNode;
+ /** Rendered as the page itself. */
+ content: ReactNode;
+}
+
+/** A row in the profile's navigation that leaves for somewhere else. */
+export interface CustomProfileLink {
+ /** Names the row in the profile's navigation. */
+ label: string;
+ /** Where the row goes. */
+ href: string;
+ path?: never;
+ icon?: ReactNode;
+ content?: never;
+}
+
+export type CustomProfileItem = CustomProfilePage | CustomProfileLink;
+
+export interface CustomPagesOptions {
+ /** Pages and links of the consumer's own. */
+ items: CustomProfileItem[] | undefined;
+ /** The order the profile's navigation should run in, by id. */
+ order: readonly string[] | undefined;
+ /** The profile's own pages, in the order it shows them, minus any this instance has turned off. */
+ builtInPages: readonly string[];
+}
+
+export interface CustomPagesBridge {
+ /** clerk-js's own custom-page form, ready to pass to `openUserProfile`. */
+ customPages: CustomPage[] | undefined;
+ /** Render these for as long as the profile can be open, or its pages come up blank. */
+ portals: ReactNode[];
+}
+
+const isPage = (item: CustomProfileItem): item is CustomProfilePage => item.path !== undefined;
+
+/** A page is identified by where it lives, which the profile's routing already requires be unique. */
+const identify = (item: CustomProfileItem): string => (isPage(item) ? item.path : item.href);
+
+/**
+ * The ids to send, in the order the profile should show them.
+ *
+ * clerk-js puts every built-in page it was *not* asked to move ahead of everything it was, so a
+ * built-in left out of the order has to be sent anyway to keep it behind the pages that were named.
+ * Ids that match no page are dropped rather than sent: clerk-js would reject them, and does so by
+ * logging them as invalid page data, which is not what a typo in this list deserves.
+ */
+function arrange(
+ order: readonly string[],
+ items: ReadonlyMap,
+ builtInPages: readonly string[],
+): string[] {
+ const exists = (id: string) => items.has(id) || builtInPages.includes(id);
+ const named = [...new Set(order)].filter(exists);
+ const rest = [...builtInPages, ...items.keys()].filter(id => !named.includes(id));
+ return [...named, ...rest];
+}
+
+function portalInto(containers: ReadonlyMap, id: string, node: ReactNode): ReactNode {
+ const container = containers.get(id);
+ return container ? createPortal(node, container, id) : null;
+}
+
+/**
+ * Bridges custom pages written as React nodes into the DOM callbacks clerk-js takes.
+ *
+ * The profile opens in clerk-js's own React root, which cannot render a node from the host app's
+ * tree. So each page is sent as a `mount`/`unmount` pair: clerk-js renders an empty `div` where the
+ * page belongs and hands it over, and the host tree portals the content into it from here. The
+ * portals therefore have to stay mounted in the host tree the whole time the profile is open, which
+ * is why they come back out rather than being rendered here.
+ *
+ * This is the shape of the bridge only for as long as the profile renders outside the host tree. A
+ * Mosaic profile mounted in-tree renders `content` directly, and none of this survives except the
+ * props a consumer writes.
+ */
+export function useCustomPages({ items, order, builtInPages }: CustomPagesOptions): CustomPagesBridge {
+ const [containers, setContainers] = useState>(new Map());
+
+ // Keyed by id rather than closing over the element, so the callbacks a profile was opened with keep
+ // working: the portal re-reads its container from state on every render of the host tree.
+ const bind = useCallback(
+ (id: string) => ({
+ mount: (el: HTMLDivElement) => setContainers(prev => new Map(prev).set(id, el)),
+ unmount: () =>
+ setContainers(prev => {
+ const next = new Map(prev);
+ next.delete(id);
+ return next;
+ }),
+ }),
+ [],
+ );
+
+ const byId = new Map((items ?? []).map(item => [identify(item), item]));
+ const ids = order?.length ? arrange(order, byId, builtInPages) : [...byId.keys()];
+
+ if (!ids.length) {
+ return { customPages: undefined, portals: [] };
+ }
+
+ const customPages = ids.map(id => {
+ const item = byId.get(id);
+ // A built-in page, which clerk-js moves on nothing but its id. Anything else attached to it and
+ // it reads as a custom page instead.
+ if (!item) {
+ return { label: id };
+ }
+
+ // clerk-js decides what an item *is* from which callbacks are present, and drops one missing an
+ // icon pair as invalid. So the icon callbacks go out whether or not there is an icon to put
+ // through them; without them, leaving `icon` off would silently cost you the page.
+ const icon = bind(`icon:${id}`);
+ const content = isPage(item) ? bind(`content:${id}`) : undefined;
+
+ return {
+ label: item.label,
+ url: id,
+ mountIcon: icon.mount,
+ unmountIcon: icon.unmount,
+ ...(content && { mount: content.mount, unmount: content.unmount }),
+ };
+ });
+
+ const portals = (items ?? []).flatMap(item => [
+ portalInto(containers, `icon:${identify(item)}`, item.icon),
+ ...(isPage(item) ? [portalInto(containers, `content:${identify(item)}`, item.content)] : []),
+ ]);
+
+ return { customPages, portals };
+}
diff --git a/packages/ui/src/mosaic/hooks/useUserProfilePages.ts b/packages/ui/src/mosaic/hooks/useUserProfilePages.ts
new file mode 100644
index 00000000000..88c731d6d9c
--- /dev/null
+++ b/packages/ui/src/mosaic/hooks/useUserProfilePages.ts
@@ -0,0 +1,33 @@
+import {
+ disabledUserAPIKeysFeature,
+ disabledUserBillingFeature,
+} from '@clerk/shared/internal/clerk-js/componentGuards';
+import { useClerk } from '@clerk/shared/react';
+
+import { useMosaicEnvironment } from './useMosaicEnvironment';
+
+/** A page the UserProfile brings itself, named by the id its navigation knows it as. */
+export type UserProfilePageId = 'account' | 'security' | 'billing' | 'apiKeys';
+
+/**
+ * The UserProfile's own pages, in the order it lists them, minus the ones this instance has turned
+ * off.
+ *
+ * Ordering a custom page after a built-in one means naming every built-in that follows it, so the
+ * list has to match what the profile will actually show. It mirrors clerk-js rather than being read
+ * from it: the profile is not mounted yet at the point this is needed, and it decides its own pages
+ * from the same environment behind the same guards.
+ */
+export function useUserProfilePages(): UserProfilePageId[] {
+ const clerk = useClerk();
+ const environment = useMosaicEnvironment();
+
+ const pages: UserProfilePageId[] = ['account', 'security'];
+ if (!disabledUserBillingFeature(clerk, environment)) {
+ pages.push('billing');
+ }
+ if (!disabledUserAPIKeysFeature(clerk, environment)) {
+ pages.push('apiKeys');
+ }
+ return pages;
+}
diff --git a/packages/ui/src/mosaic/index.ts b/packages/ui/src/mosaic/index.ts
index f029a76256f..f3fcd148d85 100644
--- a/packages/ui/src/mosaic/index.ts
+++ b/packages/ui/src/mosaic/index.ts
@@ -3,5 +3,7 @@
// `./styles` is the build barrel, and re-exporting it would publish the headless primitive types too.
import './styles';
+export type { CustomProfileItem, CustomProfileLink, CustomProfilePage } from './hooks/useCustomPages';
+export type { UserProfilePageId } from './hooks/useUserProfilePages';
export { UserButton } from './user-button/user-button';
-export type { UserButtonProps } from './user-button/user-button';
+export type { UserButtonProps, UserButtonUserProfileProps } from './user-button/user-button';
diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx
index 98b0ba9dc00..5057f2e8dfb 100644
--- a/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx
+++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx
@@ -1,4 +1,5 @@
import type * as SharedReact from '@clerk/shared/react';
+import type { CustomPage } from '@clerk/shared/types';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -169,8 +170,8 @@ afterEach(() => {
vi.clearAllMocks();
});
-function Harness(options: UserButtonControllerOptions = {}) {
- const c = useUserButtonController(options);
+function Harness({ customPages, ...options }: UserButtonControllerOptions & { customPages?: CustomPage[] } = {}) {
+ const c = useUserButtonController(options, customPages);
if (c.status !== 'ready') {
return ;
}
@@ -588,6 +589,26 @@ describe('useUserButtonController', () => {
expect(openOrganizationProfile).toHaveBeenCalledWith({ getContainer });
});
+ // Custom pages are bridged into this DOM-callback form by the container, since it is the layer
+ // that can render their portals. All the controller owes them is a ride to the modal.
+ it('hands the profile modal the custom pages it was given', () => {
+ const customPages = [
+ {
+ label: 'Terms',
+ url: 'terms',
+ mount: vi.fn(),
+ unmount: vi.fn(),
+ mountIcon: vi.fn(),
+ unmountIcon: vi.fn(),
+ },
+ ];
+ render();
+
+ fireEvent.click(screen.getByText('manage-account'));
+
+ expect(openUserProfile).toHaveBeenCalledWith({ getContainer, customPages });
+ });
+
// A URL is the whole opt-in: passing one means navigation, with no mode to remember to pass
// alongside it. The two are resolved apart, so routing one profile leaves the other a modal.
it('navigates to a profile URL when one is given, and only for that profile', () => {
diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx
index 67dca43dc6b..18f91373846 100644
--- a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx
+++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx
@@ -1,5 +1,6 @@
import type * as SharedReact from '@clerk/shared/react';
-import { render, screen, waitFor, within } from '@testing-library/react';
+import type { CustomPage } from '@clerk/shared/types';
+import { act as reactAct, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -82,6 +83,8 @@ vi.mock('@clerk/shared/react', async importOriginal => {
__internal_environment: {
displayConfig: { afterSwitchSessionUrl: '/after-switch' },
authConfig: { singleSessionMode },
+ commerceSettings: { billing: { user: { enabled: false } } },
+ apiKeysSettings: { user_api_keys_enabled: false },
},
}),
};
@@ -349,6 +352,48 @@ describe('UserButton (connected)', () => {
await waitFor(() => expect(popup()).toBeNull());
});
+ // The whole round trip for a custom page: the prop a consumer writes, through the bridge, out to
+ // the callbacks clerk-js is handed, and back into the element clerk-js renders for the page. The
+ // popover has closed by then, so this also covers the portals outliving what opened them.
+ it('renders a custom page into the element the opened profile hands back', async () => {
+ renderUserButton({
+ userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content: Terms body
}] },
+ });
+ const act = await open();
+
+ await accountAction(act, 'Manage account');
+ await waitFor(() => expect(popup()).toBeNull());
+
+ const { customPages } = openUserProfile.mock.calls[0][0];
+ expect(customPages).toHaveLength(1);
+ expect(customPages[0]).toMatchObject({ label: 'Terms', url: 'terms' });
+
+ // Stands in for clerk-js's `ExternalElementMounter`, which renders this `div` where the page goes.
+ const el = document.createElement('div');
+ document.body.appendChild(el);
+ reactAct(() => {
+ customPages[0].mount(el);
+ });
+
+ expect(within(el).getByText('Terms body')).toBeInTheDocument();
+ });
+
+ it('opens the profile with its pages in the order it was given', async () => {
+ renderUserButton({
+ userProfileProps: {
+ customPages: [{ label: 'Terms', path: 'terms', content: Terms body
}],
+ pageOrder: ['account', 'terms'],
+ },
+ });
+ const act = await open();
+
+ await accountAction(act, 'Manage account');
+ await waitFor(() => expect(popup()).toBeNull());
+
+ const { customPages } = openUserProfile.mock.calls[0][0];
+ expect(customPages.map((page: CustomPage) => page.label)).toEqual(['account', 'Terms', 'security']);
+ });
+
it('inviting members opens the InviteMembers modal and closes the popover', async () => {
renderUserButton();
const act = await open();
diff --git a/packages/ui/src/mosaic/user-button/user-button.controller.tsx b/packages/ui/src/mosaic/user-button/user-button.controller.tsx
index 2b195707097..d46dc583a0b 100644
--- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx
+++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx
@@ -1,6 +1,6 @@
import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user';
import { useClerk, useOrganization, usePortalRoot, useSession, useUser } from '@clerk/shared/react';
-import type { OrganizationResource, UserResource } from '@clerk/shared/types';
+import type { CustomPage, OrganizationResource, UserResource } from '@clerk/shared/types';
import { populateParamFromObject } from '../../contexts/utils';
import { useOrganizationListInView } from '../../hooks/useOrganizationListInView';
@@ -124,7 +124,15 @@ function toSession(sessionId: string, user: UserResource): UserButtonSession {
};
}
-export function useUserButtonController(options?: UserButtonControllerOptions): UserButtonController {
+/**
+ * @param userProfileCustomPages - The consumer's custom pages, already bridged into clerk-js's
+ * DOM-callback form. The container owns that conversion because it is the layer that can render
+ * the portals behind it, so they arrive here ready to forward and stay out of the public options.
+ */
+export function useUserButtonController(
+ options?: UserButtonControllerOptions,
+ userProfileCustomPages?: CustomPage[],
+): UserButtonController {
const { isLoaded: isUserLoaded, user } = useUser();
const { isLoaded: isSessionLoaded, session } = useSession();
const { isLoaded: isOrgLoaded, organization } = useOrganization();
@@ -145,7 +153,7 @@ export function useUserButtonController(options?: UserButtonControllerOptions):
const manageAccount = openOrNavigate({
url: options?.userProfileUrl,
mode: options?.userProfileMode,
- openModal: () => clerk.openUserProfile({ getContainer }),
+ openModal: () => clerk.openUserProfile({ getContainer, customPages: userProfileCustomPages }),
buildUrl: () => clerk.buildUserProfileUrl(),
navigate: router.navigate,
});
diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx
index 199be30e0d6..b10e4980453 100644
--- a/packages/ui/src/mosaic/user-button/user-button.tsx
+++ b/packages/ui/src/mosaic/user-button/user-button.tsx
@@ -2,15 +2,33 @@
import { useState } from 'react';
+import type { CustomProfileItem } from '../hooks/useCustomPages';
+import { useCustomPages } from '../hooks/useCustomPages';
import { useSpinDelay } from '../hooks/useSpinDelay';
+import type { UserProfilePageId } from '../hooks/useUserProfilePages';
+import { useUserProfilePages } from '../hooks/useUserProfilePages';
import type { UserButtonController, UserButtonControllerOptions } from './user-button.controller';
import { useUserButtonController } from './user-button.controller';
import type { UserButtonRootProps, UserButtonTriggerProps } from './user-button.view';
import { userButtonBusyKeys, UserButtonTriggerSkeleton, UserButtonView } from './user-button.view';
+/** Configures the UserProfile this button opens. */
+export interface UserButtonUserProfileProps {
+ /** Pages and links of your own, added to the profile's navigation. */
+ customPages?: CustomProfileItem[];
+ /**
+ * The order the profile's navigation runs in, by id: a built-in page's id, or a custom entry's
+ * `path` or `href`. Anything left out follows the pages named here. The first page is the one the
+ * profile opens on, so it cannot be a link.
+ */
+ pageOrder?: (UserProfilePageId | (string & {}))[];
+}
+
export type UserButtonProps = UserButtonControllerOptions &
UserButtonTriggerProps &
- Pick;
+ Pick & {
+ userProfileProps?: UserButtonUserProfileProps;
+ };
/** The one action in flight: which affordance owns it, and what the surface froze on to run it. */
interface PendingAction {
@@ -31,9 +49,19 @@ export function UserButton({
renderPlanBadge,
mode,
modePriority,
+ userProfileProps,
...options
}: UserButtonProps = {}) {
- const controller = useUserButtonController(options);
+ // The profile opens in clerk-js's own React root, so its custom pages reach it as portals rendered
+ // from here. They have to outlive the popover that opened it, and the button's own data with it,
+ // which is why they hang off the container rather than anything the popover renders.
+ const builtInPages = useUserProfilePages();
+ const { customPages, portals } = useCustomPages({
+ items: userProfileProps?.customPages,
+ order: userProfileProps?.pageOrder,
+ builtInPages,
+ });
+ const controller = useUserButtonController(options, customPages);
const [open, setOpen] = useState(false);
const [action, setAction] = useState(null);
@@ -43,15 +71,18 @@ export function UserButton({
if (controller.status === 'loading') {
return (
-
+ <>
+
+ {portals}
+ >
);
}
if (controller.status !== 'ready') {
- return null;
+ return <>{portals}>;
}
const close = () => setOpen(false);
@@ -108,26 +139,29 @@ export function UserButton({
} = action?.snapshot ?? controller;
return (
-
+ <>
+
+ {portals}
+ >
);
}
From 1437c76bf571b10f11535384321a965ad317269f Mon Sep 17 00:00:00 2001
From: Alex Carpenter
Date: Wed, 5 Aug 2026 19:26:49 -0400
Subject: [PATCH 2/2] feat(ui): key custom profile pages and links on path
Ordering keyed on href for links; path is now required on every entry and is the pageOrder key.
---
.../hooks/__tests__/useCustomPages.test.tsx | 9 +++++++--
.../ui/src/mosaic/hooks/useCustomPages.tsx | 19 +++++++++----------
.../ui/src/mosaic/user-button/user-button.tsx | 4 ++--
3 files changed, 18 insertions(+), 14 deletions(-)
diff --git a/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx b/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx
index e3987d293bb..e209673b17a 100644
--- a/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx
+++ b/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx
@@ -35,7 +35,12 @@ const terms: CustomProfileItem = {
content: Terms body
,
};
-const docs: CustomProfileItem = { label: 'Docs', href: 'https://clerk.com/docs', icon: docs icon };
+const docs: CustomProfileItem = {
+ label: 'Docs',
+ path: 'docs',
+ href: 'https://clerk.com/docs',
+ icon: docs icon,
+};
beforeEach(() => {
emitted = undefined;
@@ -145,7 +150,7 @@ describe('useCustomPages', () => {
render(
,
);
diff --git a/packages/ui/src/mosaic/hooks/useCustomPages.tsx b/packages/ui/src/mosaic/hooks/useCustomPages.tsx
index 87e916b9b9d..f406a0d3dad 100644
--- a/packages/ui/src/mosaic/hooks/useCustomPages.tsx
+++ b/packages/ui/src/mosaic/hooks/useCustomPages.tsx
@@ -19,9 +19,10 @@ export interface CustomProfilePage {
export interface CustomProfileLink {
/** Names the row in the profile's navigation. */
label: string;
+ /** Identifies the row, for ordering. */
+ path: string;
/** Where the row goes. */
href: string;
- path?: never;
icon?: ReactNode;
content?: never;
}
@@ -44,10 +45,7 @@ export interface CustomPagesBridge {
portals: ReactNode[];
}
-const isPage = (item: CustomProfileItem): item is CustomProfilePage => item.path !== undefined;
-
-/** A page is identified by where it lives, which the profile's routing already requires be unique. */
-const identify = (item: CustomProfileItem): string => (isPage(item) ? item.path : item.href);
+const isLink = (item: CustomProfileItem): item is CustomProfileLink => item.href !== undefined;
/**
* The ids to send, in the order the profile should show them.
@@ -104,7 +102,7 @@ export function useCustomPages({ items, order, builtInPages }: CustomPagesOption
[],
);
- const byId = new Map((items ?? []).map(item => [identify(item), item]));
+ const byId = new Map((items ?? []).map(item => [item.path, item]));
const ids = order?.length ? arrange(order, byId, builtInPages) : [...byId.keys()];
if (!ids.length) {
@@ -123,11 +121,12 @@ export function useCustomPages({ items, order, builtInPages }: CustomPagesOption
// icon pair as invalid. So the icon callbacks go out whether or not there is an icon to put
// through them; without them, leaving `icon` off would silently cost you the page.
const icon = bind(`icon:${id}`);
- const content = isPage(item) ? bind(`content:${id}`) : undefined;
+ const content = isLink(item) ? undefined : bind(`content:${id}`);
return {
label: item.label,
- url: id,
+ // A page is routed to by its path; a link is followed to wherever it points.
+ url: isLink(item) ? item.href : item.path,
mountIcon: icon.mount,
unmountIcon: icon.unmount,
...(content && { mount: content.mount, unmount: content.unmount }),
@@ -135,8 +134,8 @@ export function useCustomPages({ items, order, builtInPages }: CustomPagesOption
});
const portals = (items ?? []).flatMap(item => [
- portalInto(containers, `icon:${identify(item)}`, item.icon),
- ...(isPage(item) ? [portalInto(containers, `content:${identify(item)}`, item.content)] : []),
+ portalInto(containers, `icon:${item.path}`, item.icon),
+ ...(isLink(item) ? [] : [portalInto(containers, `content:${item.path}`, item.content)]),
]);
return { customPages, portals };
diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx
index b10e4980453..96612bd417a 100644
--- a/packages/ui/src/mosaic/user-button/user-button.tsx
+++ b/packages/ui/src/mosaic/user-button/user-button.tsx
@@ -18,8 +18,8 @@ export interface UserButtonUserProfileProps {
customPages?: CustomProfileItem[];
/**
* The order the profile's navigation runs in, by id: a built-in page's id, or a custom entry's
- * `path` or `href`. Anything left out follows the pages named here. The first page is the one the
- * profile opens on, so it cannot be a link.
+ * `path`. Anything left out follows the pages named here. The first page is the one the profile
+ * opens on, so it cannot be a link.
*/
pageOrder?: (UserProfilePageId | (string & {}))[];
}