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 {c.status}; } @@ -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 & {}))[]; }