diff --git a/.changeset/mosaic-user-button-integration.md b/.changeset/mosaic-user-button-integration.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-user-button-integration.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/ui/src/mosaic/components/button/submit-button.test.tsx b/packages/ui/src/mosaic/components/button/submit-button.test.tsx
index b8398f07523..d8403b86d16 100644
--- a/packages/ui/src/mosaic/components/button/submit-button.test.tsx
+++ b/packages/ui/src/mosaic/components/button/submit-button.test.tsx
@@ -302,19 +302,29 @@ describe('Mosaic SubmitButton spin delay', () => {
expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});
- // A consumer who already knows the action is slow has nothing to gain by waiting.
+ // A consumer who already knows the action is slow has nothing to gain by waiting: there is no
+ // delay left to outlast, so the spinner shows in the render that starts the action rather than a
+ // timer's.
it('lets the consumer opt out of the delay', () => {
- render(
+ const { rerender } = render(
Save
,
);
const hidden = atoms(spinner());
- advance(0);
+ rerender(
+
+ Save
+ ,
+ );
+
expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});
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..2cdc8c485fb
--- /dev/null
+++ b/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx
@@ -0,0 +1,208 @@
+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',
+ path: '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();
+ });
+
+ // Without them clerk-js drops the page as invalid, so `icon` could not be optional.
+ 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']);
+ });
+
+ // Anything more than the id and clerk-js reads it as a custom page.
+ it('sends a built-in page as its id alone', () => {
+ render();
+
+ expect(emitted).toEqual([{ label: 'security' }, { label: 'account' }]);
+ });
+
+ // Unsent built-ins jump to the front, so one left out of the order would not stay put.
+ 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/__tests__/useSpinDelay.test.ts b/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
index 66052c26835..3ac3f63839e 100644
--- a/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
+++ b/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
@@ -76,6 +76,27 @@ describe('useSpinDelay', () => {
expect(result.current).toBeNull();
});
+ // Direct feedback on a click has nothing to debounce, so a zero delay must not cost a timer's
+ // worth of render passes before the spinner appears.
+ it('surfaces the value in the same pass when there is no delay to wait out', async () => {
+ const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
+ await act(() => rerender({ value: 'a' }));
+
+ expect(result.current).toBe('a');
+ });
+
+ it('still holds a zero-delay value for minDuration', async () => {
+ const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
+ await act(() => rerender({ value: 'a' }));
+ await act(() => rerender({ value: null }));
+
+ await advance(199);
+ expect(result.current).toBe('a');
+
+ await advance(1);
+ expect(result.current).toBeNull();
+ });
+
it('swaps to a new value immediately when one replaces another mid-show', async () => {
const { result, rerender } = render(null, { delay: 500, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
diff --git a/packages/ui/src/mosaic/hooks/useCustomPages.tsx b/packages/ui/src/mosaic/hooks/useCustomPages.tsx
new file mode 100644
index 00000000000..f406a0d3dad
--- /dev/null
+++ b/packages/ui/src/mosaic/hooks/useCustomPages.tsx
@@ -0,0 +1,142 @@
+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;
+ /** Identifies the row, for ordering. */
+ path: string;
+ /** Where the row goes. */
+ href: string;
+ 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 isLink = (item: CustomProfileItem): item is CustomProfileLink => item.href !== undefined;
+
+/**
+ * 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 => [item.path, 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 = isLink(item) ? undefined : bind(`content:${id}`);
+
+ return {
+ label: item.label,
+ // 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 }),
+ };
+ });
+
+ const portals = (items ?? []).flatMap(item => [
+ 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/hooks/useSpinDelay.ts b/packages/ui/src/mosaic/hooks/useSpinDelay.ts
index b847c0bc517..dac6bc682b7 100644
--- a/packages/ui/src/mosaic/hooks/useSpinDelay.ts
+++ b/packages/ui/src/mosaic/hooks/useSpinDelay.ts
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
export interface SpinDelayOptions {
- /** Wait this long before showing the value, so quick actions never flash a spinner. */
+ /** Wait this long before showing the value, so quick actions never flash a spinner. `0` shows it straight away. */
delay?: number;
/** Once shown, keep the value up at least this long, so the spinner never flickers off. */
minDuration?: number;
@@ -25,11 +25,17 @@ export function useSpinDelay(value: T | null, options: SpinDelayOptions = {})
const shownAt = useRef(0);
useEffect(() => {
- // Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`.
+ // Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`. With no
+ // delay there is nothing to outlast, so it surfaces in this pass rather than a timer's.
if (shown === null) {
if (value === null) {
return;
}
+ if (delay <= 0) {
+ shownAt.current = Date.now();
+ setShown(value);
+ return;
+ }
const timer = setTimeout(() => {
shownAt.current = Date.now();
setShown(value);
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/user-button/__tests__/user-button.controller.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx
index 6dcab2f28bf..81164a0cbc6 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 ;
}
@@ -582,6 +583,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
new file mode 100644
index 00000000000..dfc792eb308
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx
@@ -0,0 +1,570 @@
+import type * as SharedReact from '@clerk/shared/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';
+
+import { MosaicProvider } from '../../MosaicProvider';
+import type { UserButtonProps } from '../user-button';
+import { UserButton } from '../user-button';
+
+// End-to-end wiring test for the connected UserButton: it renders the real view through the real
+// controller against a mocked Clerk, then drives the real popover DOM. Unlike the controller test
+// (controller -> Clerk), this proves the layers compose, including what closes the popover:
+// selecting a workspace closes on success in the machine, and anything that opens a modal or
+// navigates closes before it hands off.
+
+interface FakeUser {
+ id: string;
+ firstName: string | null;
+ lastName: string | null;
+ username: string | null;
+ primaryEmailAddress: { emailAddress: string } | null;
+ imageUrl: string;
+ organizationMemberships: unknown[];
+ createOrganizationEnabled: boolean;
+}
+
+interface FakeSession {
+ id: string;
+ user: FakeUser;
+}
+
+interface FakeList {
+ data: unknown[];
+ count: number;
+ hasNextPage: boolean;
+ isLoading: boolean;
+ revalidate: ReturnType;
+}
+
+let isUserLoaded: boolean;
+let isSessionLoaded: boolean;
+let isOrgLoaded: boolean;
+let user: FakeUser | null;
+let session: { id: string; checkAuthorization: ReturnType } | null;
+let organization: { id: string; name: string; imageUrl: string; membersCount: number } | null;
+let userMemberships: FakeList;
+let userInvitations: FakeList;
+let userSuggestions: FakeList;
+let signedInSessions: FakeSession[];
+let pagingRef: ReturnType;
+let singleSessionMode: boolean;
+let organizationsEnabled: boolean;
+
+let setActive: ReturnType;
+let signOut: ReturnType;
+let navigate: ReturnType;
+let openUserProfile: ReturnType;
+let openOrganizationProfile: ReturnType;
+let openCreateOrganization: ReturnType;
+let openInviteMembers: ReturnType;
+
+vi.mock('@clerk/shared/react', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useUser: () => ({ isLoaded: isUserLoaded, user }),
+ useSession: () => ({ isLoaded: isSessionLoaded, session }),
+ useOrganization: () => ({ isLoaded: isOrgLoaded, organization }),
+ useClerk: () => ({
+ navigate,
+ setActive,
+ signOut,
+ openUserProfile,
+ openOrganizationProfile,
+ openCreateOrganization,
+ openInviteMembers,
+ buildUserProfileUrl: () => '/user-profile',
+ buildOrganizationProfileUrl: () => '/org-profile',
+ buildCreateOrganizationUrl: () => '/create-org',
+ buildSignInUrl: () => '/sign-in',
+ buildAfterSignOutUrl: () => '/after-sign-out',
+ buildAfterMultiSessionSingleSignOutUrl: () => '/after-single-sign-out',
+ client: { signedInSessions },
+ __internal_environment: {
+ displayConfig: { afterSwitchSessionUrl: '/after-switch' },
+ authConfig: { singleSessionMode },
+ organizationSettings: { enabled: organizationsEnabled },
+ commerceSettings: { billing: { user: { enabled: false } } },
+ apiKeysSettings: { user_api_keys_enabled: false },
+ },
+ }),
+ };
+});
+
+// Stubbed at the same seam as the controller test: the in-view helper is the controller's whole
+// fetch boundary, so `ref` doubles as the assertion that the paging sentinel mounted.
+vi.mock('../../../hooks/useOrganizationListInView', () => ({
+ useOrganizationListInView: () => ({ userMemberships, userInvitations, userSuggestions, ref: pagingRef }),
+}));
+
+function acceptable(id: string, orgId: string, orgName: string, status: 'pending' | 'accepted' = 'pending') {
+ return {
+ id,
+ status,
+ accept: vi.fn().mockResolvedValue(undefined),
+ publicOrganizationData: { id: orgId, name: orgName, imageUrl: '' },
+ };
+}
+
+function membership(orgId: string, name: string, membersCount: number) {
+ return { organization: { id: orgId, name, imageUrl: '', membersCount } };
+}
+
+function list(data: unknown[], count: number, hasNextPage = false, isLoading = false): FakeList {
+ return { data, count, hasNextPage, isLoading, revalidate: vi.fn().mockResolvedValue(undefined) };
+}
+
+/** A promise whose settling is controlled by the test, to hold an async action in flight. */
+function createDeferred() {
+ let resolve: () => void = () => {};
+ let reject: (reason?: unknown) => void = () => {};
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+beforeEach(() => {
+ isUserLoaded = true;
+ isSessionLoaded = true;
+ isOrgLoaded = true;
+ user = {
+ id: 'user_1',
+ firstName: 'Alice',
+ lastName: 'Smith',
+ username: 'alice',
+ primaryEmailAddress: { emailAddress: 'alice@example.com' },
+ imageUrl: 'https://img/alice',
+ organizationMemberships: [{ id: 'orgmem_1' }],
+ createOrganizationEnabled: true,
+ };
+ session = { id: 'sess_1', checkAuthorization: vi.fn().mockReturnValue(true) };
+ organization = { id: 'org_1', name: 'Acme', imageUrl: '', membersCount: 3 };
+ userMemberships = list([membership('org_1', 'Acme', 3), membership('org_9', 'Other', 1)], 2);
+ userInvitations = list([acceptable('inv_1', 'org_3', 'Gamma')], 1);
+ userSuggestions = list([acceptable('sug_1', 'org_2', 'Beta')], 1);
+ pagingRef = vi.fn();
+ singleSessionMode = false;
+ organizationsEnabled = true;
+ signedInSessions = [
+ { id: 'sess_1', user },
+ {
+ id: 'sess_2',
+ user: {
+ id: 'user_2',
+ firstName: 'Bob',
+ lastName: 'Jones',
+ username: null,
+ primaryEmailAddress: { emailAddress: 'bob@example.com' },
+ imageUrl: 'https://img/bob',
+ organizationMemberships: [],
+ createOrganizationEnabled: true,
+ },
+ },
+ ];
+ setActive = vi.fn().mockResolvedValue(undefined);
+ signOut = vi.fn().mockResolvedValue(undefined);
+ navigate = vi.fn().mockResolvedValue(undefined);
+ openUserProfile = vi.fn();
+ openOrganizationProfile = vi.fn();
+ openCreateOrganization = vi.fn();
+ openInviteMembers = vi.fn();
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+function renderUserButton(props: UserButtonProps = {}) {
+ return render(
+
+ {/* The button portals its popup out, so this host holds only what it renders in place. */}
+
+
+
+ ,
+ );
+}
+
+const host = () => screen.getByTestId('host');
+const trigger = () => screen.getByRole('button', { name: /Open account menu/ });
+const popup = () => screen.queryByRole('dialog', { name: 'Account' });
+const spinner = () => popup()?.querySelector('.cl-spinner') ?? null;
+
+async function open() {
+ const act = userEvent.setup();
+ await act.click(trigger());
+ expect(popup()).toBeInTheDocument();
+ return act;
+}
+
+// Alice has a username, so that is what identifies her row; Bob has none and falls back to email.
+const accountMenu = () => screen.getByRole('button', { name: 'Actions for alice' });
+
+/** Opens the `⋯` on the active account's row and clicks one of its actions. */
+async function accountAction(act: ReturnType, label: string) {
+ await act.click(accountMenu());
+ await act.click(await screen.findByRole('menuitem', { name: label }));
+}
+
+describe('UserButton (connected)', () => {
+ // Nothing stands in for the button before Clerk answers, in any mode: until it does, a signed-out
+ // visitor is indistinguishable from a session still resolving, so a placeholder here would be
+ // promising a button to people who never get one.
+ describe.each(['combined', 'organization', 'user'] as const)('in %s mode', mode => {
+ it('renders nothing while Clerk is still loading', () => {
+ isUserLoaded = false;
+ renderUserButton({ mode });
+ expect(host()).toBeEmptyDOMElement();
+ });
+
+ it('renders nothing when nobody is signed in', () => {
+ user = null;
+ renderUserButton({ mode });
+ expect(host()).toBeEmptyDOMElement();
+ });
+
+ // Organizations off at the instance is the same answer whatever mode asked for: the button is
+ // the account's. An org-only surface would otherwise render its own empty shell, since the
+ // clerk-js mount boundary that withholds `` never runs for this one.
+ it('leaves organizations out entirely when the instance has them disabled', async () => {
+ organizationsEnabled = false;
+ renderUserButton({ mode });
+
+ // The account heads the surface, rather than the organization that is active regardless.
+ expect(screen.getByRole('button', { name: 'Open account menu for Alice Smith' })).toBeInTheDocument();
+ await open();
+
+ for (const name of ['Acme', 'Other', 'Beta', 'Gamma', 'Personal account']) {
+ expect(screen.queryByText(name)).toBeNull();
+ }
+ expect(screen.queryByRole('button', { name: 'Create organization' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull();
+
+ // Everything the account itself carries is still on offer.
+ expect(screen.getByRole('button', { name: 'Sign out' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'bob@example.com' })).toBeInTheDocument();
+ });
+ });
+
+ it('renders the trigger and keeps the popover closed until clicked', () => {
+ renderUserButton();
+ expect(trigger()).toBeInTheDocument();
+ expect(popup()).toBeNull();
+ });
+
+ it('opens the popover on trigger click', async () => {
+ renderUserButton();
+ await open();
+
+ expect(screen.getByRole('button', { name: 'Other' })).toBeInTheDocument();
+ expect(accountMenu()).toBeInTheDocument();
+ });
+
+ it('selecting an organization calls setActive without a redirect by default and closes the popover', async () => {
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Other' }));
+
+ expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined });
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ it('leaving the active organization for the personal workspace clears it', async () => {
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Personal account' }));
+
+ expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: undefined });
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ it('drops the personal workspace where the app hides it, leaving the organizations', async () => {
+ renderUserButton({ hidePersonal: true });
+ await open();
+
+ expect(screen.queryByText('Personal account')).toBeNull();
+ expect(screen.getByRole('button', { name: 'Other' })).toBeInTheDocument();
+ });
+
+ // `mode` is the view's own prop; this only proves the connected component hands it down, since
+ // the account-only surface is otherwise indistinguishable from an account with no organizations.
+ it('forwards mode to the view, so an account-only surface lists no organizations', async () => {
+ renderUserButton({ mode: 'user' });
+ await open();
+
+ expect(screen.queryByRole('button', { name: 'Other' })).toBeNull();
+ expect(screen.getByRole('button', { name: 'bob@example.com' })).toBeInTheDocument();
+ });
+
+ it('switching to another account calls setActive with the session and stays open', async () => {
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'bob@example.com' }));
+
+ expect(setActive).toHaveBeenCalledWith({ session: 'sess_2', redirectUrl: '/after-switch' });
+ await waitFor(() => expect(spinner()).toBeNull());
+ expect(popup()).toBeInTheDocument();
+ });
+
+ it('signing out of the active account calls signOut with its session id', async () => {
+ renderUserButton();
+ const act = await open();
+
+ await accountAction(act, 'Sign out');
+
+ // Another account stays signed in, so this is a single sign out, not a full one.
+ expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_1', redirectUrl: '/after-single-sign-out' });
+ });
+
+ it('signing out of all accounts calls signOut with the after-sign-out url', async () => {
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Sign out of all accounts' }));
+
+ expect(signOut).toHaveBeenCalledWith({ redirectUrl: '/after-sign-out' });
+ });
+
+ it('accepting an invitation accepts it, revalidates, and stays open', async () => {
+ renderUserButton();
+ const act = await open();
+ const invitation = userInvitations.data[0] as ReturnType;
+
+ await act.click(screen.getByRole('button', { name: 'Accept' }));
+
+ await waitFor(() => expect(invitation.accept).toHaveBeenCalledTimes(1));
+ expect(userInvitations.revalidate).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(spinner()).toBeNull());
+ expect(popup()).toBeInTheDocument();
+ });
+
+ it('accepting a suggestion accepts it, revalidates, and stays open', async () => {
+ renderUserButton();
+ const act = await open();
+ const suggestion = userSuggestions.data[0] as ReturnType;
+
+ await act.click(screen.getByRole('button', { name: 'Join' }));
+
+ await waitFor(() => expect(suggestion.accept).toHaveBeenCalledTimes(1));
+ expect(userSuggestions.revalidate).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(spinner()).toBeNull());
+ expect(popup()).toBeInTheDocument();
+ });
+
+ it('drops add-account and sign-out-of-all in single-session mode', async () => {
+ singleSessionMode = true;
+ signedInSessions = signedInSessions.slice(0, 1);
+ renderUserButton();
+ const act = await open();
+
+ expect(screen.queryByRole('button', { name: 'Sign out of all accounts' })).toBeNull();
+ expect(screen.queryByLabelText('Account actions')).toBeNull();
+ await act.click(accountMenu());
+ expect(screen.queryByRole('menuitem', { name: 'Add account' })).toBeNull();
+ });
+
+ it('managing the account opens the UserProfile modal and closes the popover', async () => {
+ renderUserButton();
+ const act = await open();
+
+ await accountAction(act, 'Manage account');
+
+ expect(openUserProfile).toHaveBeenCalled();
+ expect(navigate).not.toHaveBeenCalled();
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ // A custom action is the app's to run, and whatever it opens takes over from here, so the popover
+ // goes with it the way it does for managing an account.
+ it('running a custom menu item calls back and closes the popover', async () => {
+ const onClick = vi.fn();
+ renderUserButton({ customMenuItems: [{ id: 'terms', label: 'Terms of service', onClick }] });
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Terms of service' }));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ 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();
+
+ await act.click(screen.getByRole('button', { name: 'Invite' }));
+
+ expect(openInviteMembers).toHaveBeenCalled();
+ expect(navigate).not.toHaveBeenCalled();
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ it('creating an organization opens the modal and closes the popover', async () => {
+ renderUserButton();
+ const act = await open();
+
+ await accountAction(act, 'Create organization');
+
+ expect(openCreateOrganization).toHaveBeenCalled();
+ expect(navigate).not.toHaveBeenCalled();
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ it('creating an organization navigates instead when a URL routes it', async () => {
+ renderUserButton({ createOrganizationUrl: '/new-org' });
+ const act = await open();
+
+ await accountAction(act, 'Create organization');
+
+ expect(navigate).toHaveBeenCalledWith('/new-org');
+ expect(openCreateOrganization).not.toHaveBeenCalled();
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ it('leaves create-organization out of the account menu for a user who cannot open one', async () => {
+ user = { ...(user as FakeUser), createOrganizationEnabled: false };
+ renderUserButton();
+ const act = await open();
+ await act.click(accountMenu());
+
+ expect(await screen.findByRole('menuitem', { name: 'Manage account' })).toBeInTheDocument();
+ expect(screen.queryByRole('menuitem', { name: 'Create organization' })).toBeNull();
+ });
+
+ it('spins the clicked affordance and stands every other one down while an action is in flight', async () => {
+ const deferred = createDeferred();
+ setActive.mockReturnValueOnce(deferred.promise);
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Other' }));
+
+ // Every one of these is a network round trip, so there is nothing to debounce: the click gets
+ // its spinner in the same pass rather than after a delay window.
+ expect(spinner()).toBeInTheDocument();
+ // A stood-down row stays a button, disabled. Dropping it to a static row would remount it,
+ // and with it the avatar it carries.
+ expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'bob@example.com' })).toBeDisabled();
+ expect(popup()).toBeInTheDocument();
+
+ deferred.resolve();
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ // `setActive` swaps the active organization mid-flight. See `frozen` in the machine.
+ it('holds the surface on the data it started with until the action settles', async () => {
+ const deferred = createDeferred();
+ setActive.mockReturnValueOnce(deferred.promise);
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Other' }));
+ organization = { id: 'org_9', name: 'Other', imageUrl: '', membersCount: 1 };
+
+ // Any re-render now reads the swapped organization; the surface must not follow it.
+ await waitFor(() => expect(spinner()).toBeInTheDocument());
+ const surface = popup();
+ if (!surface) {
+ throw new Error('expected the popover to be open');
+ }
+ // Still the organization the surface opened on: heading it and listed under it, unclickable.
+ expect(within(surface).getAllByText('Acme')).toHaveLength(2);
+ expect(screen.queryByRole('button', { name: 'Acme' })).toBeNull();
+
+ deferred.resolve();
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ it('spins inside the join button while a suggestion is being joined', async () => {
+ const deferred = createDeferred();
+ const suggestion = userSuggestions.data[0] as ReturnType;
+ suggestion.accept.mockReturnValueOnce(deferred.promise);
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Join' }));
+
+ const join = screen.getByRole('button', { name: 'Join' });
+ expect(join).toHaveAttribute('aria-busy', 'true');
+ expect(within(join).getByRole('progressbar')).toBeInTheDocument();
+
+ deferred.resolve();
+ await waitFor(() => expect(spinner()).toBeNull());
+ expect(popup()).toBeInTheDocument();
+ });
+
+ it('keeps the popover open and clears busy state when an action rejects', async () => {
+ const deferred = createDeferred();
+ setActive.mockReturnValueOnce(deferred.promise);
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Other' }));
+ expect(spinner()).toBeInTheDocument();
+
+ deferred.reject(new Error('setActive failed'));
+
+ await waitFor(() => expect(spinner()).toBeNull(), { timeout: 2000 });
+ expect(popup()).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeEnabled();
+ });
+
+ // The view decides whether to mount the sentinel at all; this is the wiring that carries the
+ // in-view ref from the paginated lists, through the controller, to it.
+ it('hands the paging sentinel to the in-view ref when a list has a next page', async () => {
+ userMemberships = list([membership('org_1', 'Acme', 3)], 1, true);
+ renderUserButton();
+ await open();
+
+ expect(pagingRef).toHaveBeenCalledWith(expect.any(HTMLElement));
+ });
+});
diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.machine.test.ts b/packages/ui/src/mosaic/user-button/__tests__/user-button.machine.test.ts
new file mode 100644
index 00000000000..3d9201a1e22
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.machine.test.ts
@@ -0,0 +1,152 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { createActor } from '../../machine/createActor';
+import type { UserButtonReadyController } from '../user-button.machine';
+import { userButtonMachine } from '../user-button.machine';
+
+const tick = () => new Promise(resolve => setTimeout(resolve, 0));
+
+const ready: UserButtonReadyController = {
+ status: 'ready',
+ activeSession: { sessionId: 'sess_1', name: 'Alice', identifier: 'alice@example.com' },
+ activeOrganization: null,
+ hasOrganizations: false,
+ memberships: [],
+ suggestions: [],
+ invitations: [],
+ additionalSessions: [],
+};
+
+const run = (
+ overrides: Partial<{ key: string; run: () => Promise; closeOnSuccess: boolean }> = {},
+): {
+ type: 'RUN';
+ key: string;
+ frozen: UserButtonReadyController;
+ run: () => Promise;
+ closeOnSuccess: boolean;
+} => ({
+ type: 'RUN',
+ key: 'selectOrganization:org_1',
+ frozen: ready,
+ run: () => Promise.resolve(),
+ closeOnSuccess: false,
+ ...overrides,
+});
+
+const opened = () => {
+ const actor = createActor(userButtonMachine);
+ actor.start();
+ actor.send({ type: 'OPEN' });
+ return actor;
+};
+
+describe('userButtonMachine', () => {
+ it('starts closed', () => {
+ const actor = createActor(userButtonMachine);
+ actor.start();
+
+ expect(actor.getSnapshot().value).toBe('closed');
+ });
+
+ it('opens and closes', () => {
+ const actor = opened();
+ expect(actor.getSnapshot().value).toBe('open');
+
+ actor.send({ type: 'CLOSE' });
+ expect(actor.getSnapshot().value).toBe('closed');
+ });
+
+ it('keys the affordance, freezes the controller, and runs the injected effect', () => {
+ const effect = vi.fn(() => Promise.resolve());
+ const actor = opened();
+
+ actor.send(run({ run: effect }));
+
+ expect(actor.getSnapshot().value).toBe('busy');
+ expect(actor.getSnapshot().context.pendingKey).toBe('selectOrganization:org_1');
+ expect(actor.getSnapshot().context.frozen).toBe(ready);
+ expect(effect).toHaveBeenCalledTimes(1);
+ });
+
+ it('settles back into the open popup, releasing the freeze', async () => {
+ const actor = opened();
+
+ actor.send(run());
+ await tick();
+
+ expect(actor.getSnapshot().value).toBe('open');
+ expect(actor.getSnapshot().context.pendingKey).toBeNull();
+ expect(actor.getSnapshot().context.frozen).toBeNull();
+ });
+
+ it('closes on success for an action that ends the interaction', async () => {
+ const actor = opened();
+
+ actor.send(run({ closeOnSuccess: true }));
+ await tick();
+
+ expect(actor.getSnapshot().value).toBe('closed');
+ expect(actor.getSnapshot().context.pendingKey).toBeNull();
+ });
+
+ it('holds the popup open when an action fails, even one that would have closed it', async () => {
+ const actor = opened();
+
+ actor.send(run({ closeOnSuccess: true, run: () => Promise.reject(new Error('cannot switch')) }));
+ await tick();
+
+ expect(actor.getSnapshot().value).toBe('open');
+ expect(actor.getSnapshot().context.pendingKey).toBeNull();
+ expect(actor.getSnapshot().context.frozen).toBeNull();
+ });
+
+ it('lets the row be clicked again after a failure', async () => {
+ const actor = opened();
+
+ actor.send(run({ run: () => Promise.reject(new Error('boom')) }));
+ await tick();
+
+ const retry = vi.fn(() => Promise.resolve());
+ actor.send(run({ run: retry }));
+
+ expect(actor.getSnapshot().value).toBe('busy');
+ expect(retry).toHaveBeenCalledTimes(1);
+ });
+
+ it('refuses a second action while one is in flight', () => {
+ const second = vi.fn(() => Promise.resolve());
+ const actor = opened();
+
+ actor.send(run({ key: 'signOutAll' }));
+ actor.send(run({ key: 'switchSession:sess_2', run: second }));
+
+ expect(actor.getSnapshot().context.pendingKey).toBe('signOutAll');
+ expect(second).not.toHaveBeenCalled();
+ });
+
+ it('refuses an action while the popup is closed', () => {
+ const effect = vi.fn(() => Promise.resolve());
+ const actor = createActor(userButtonMachine);
+ actor.start();
+
+ actor.send(run({ run: effect }));
+
+ expect(actor.getSnapshot().value).toBe('closed');
+ expect(effect).not.toHaveBeenCalled();
+ });
+
+ it('abandons an action dismissed mid-flight rather than reopening on its result', async () => {
+ const actor = opened();
+
+ actor.send(run());
+ actor.send({ type: 'CLOSE' });
+
+ expect(actor.getSnapshot().value).toBe('closed');
+ expect(actor.getSnapshot().context.pendingKey).toBeNull();
+
+ await tick();
+
+ expect(actor.getSnapshot().value).toBe('closed');
+ });
+});
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 ac802fc4806..bd5e36a6687 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.machine.ts b/packages/ui/src/mosaic/user-button/user-button.machine.ts
new file mode 100644
index 00000000000..5bf1509ab56
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/user-button.machine.ts
@@ -0,0 +1,82 @@
+import { setup } from '../machine/setup';
+import type { UserButtonController } from './user-button.controller';
+
+/** The controller once Clerk has answered, which is the only shape an action can start from. */
+export type UserButtonReadyController = Extract;
+
+export interface UserButtonMachineContext {
+ /** The affordance that owns the action in flight: it spins, and every other one stands down. */
+ pendingKey: string | null;
+ /**
+ * The controller the action started from. `setActive` swaps the active organization while its
+ * promise is still in flight, so the live controller would rearrange the popup mid-action: the
+ * header renaming itself, the check jumping rows, Invite coming and going as the permission is
+ * re-read. The view renders this instead until the action settles.
+ */
+ frozen: UserButtonReadyController | null;
+ /** Injected per-action effect — the controller callback the clicked row runs. */
+ run: () => Promise;
+ /** Whether succeeding ends the interaction, and the popup with it. */
+ closeOnSuccess: boolean;
+}
+
+export type UserButtonMachineEvent =
+ | { type: 'OPEN' }
+ | { type: 'CLOSE' }
+ | {
+ type: 'RUN';
+ key: string;
+ frozen: UserButtonReadyController;
+ run: () => Promise;
+ closeOnSuccess: boolean;
+ };
+
+const { createMachine, assign, fromPromise } = setup();
+
+const settled = { pendingKey: null, frozen: null };
+
+export const userButtonMachine = createMachine({
+ id: 'userButton',
+ initial: 'closed',
+ context: {
+ pendingKey: null,
+ frozen: null,
+ run: () => Promise.resolve(),
+ closeOnSuccess: false,
+ },
+ states: {
+ closed: {
+ on: { OPEN: 'open' },
+ },
+ open: {
+ on: {
+ CLOSE: 'closed',
+ RUN: {
+ target: 'busy',
+ actions: assign((_, event) => ({
+ pendingKey: event.key,
+ frozen: event.frozen,
+ run: event.run,
+ closeOnSuccess: event.closeOnSuccess,
+ })),
+ },
+ },
+ },
+ // Reached only from `open`, so a busy popup that is not open is unrepresentable, and RUN going
+ // unhandled here is what stops a second action starting while one is in flight. Dismissing the
+ // popup abandons the action: the request finishes, but nothing is left for its result to land in.
+ busy: {
+ on: { CLOSE: { target: 'closed', actions: assign(() => settled) } },
+ invoke: fromPromise(context => context.run(), {
+ onDone: [
+ { target: 'closed', guard: context => context.closeOnSuccess, actions: assign(() => settled) },
+ { target: 'open', actions: assign(() => settled) },
+ ],
+ // The popup stays up on a failure so the row can be clicked again. Nothing reports what went
+ // wrong yet; the error surface is its own change, and carrying a message before one exists
+ // would mean shipping an untranslated string nobody reads.
+ onError: { target: 'open', actions: assign(() => settled) },
+ }),
+ },
+ },
+});
diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx
index bc9f5b10189..5a6fccefa96 100644
--- a/packages/ui/src/mosaic/user-button/user-button.tsx
+++ b/packages/ui/src/mosaic/user-button/user-button.tsx
@@ -1,23 +1,44 @@
'use client';
import type { ReactElement } from 'react';
-import { useState } from 'react';
+import type { CustomProfileItem } from '../hooks/useCustomPages';
+import { useCustomPages } from '../hooks/useCustomPages';
+import { useMosaicEnvironment } from '../hooks/useMosaicEnvironment';
import { useSpinDelay } from '../hooks/useSpinDelay';
-import { type UserButtonControllerOptions, useUserButtonController } from './user-button.controller';
+import type { UserProfilePageId } from '../hooks/useUserProfilePages';
+import { useUserProfilePages } from '../hooks/useUserProfilePages';
+import { useMachine } from '../machine/useMachine';
+import type { UserButtonControllerOptions } from './user-button.controller';
+import { useUserButtonController } from './user-button.controller';
+import { userButtonMachine } from './user-button.machine';
import type { UserButtonMenuProps, UserButtonModeProps } from './user-button.types';
import type { UserButtonTriggerProps } from './user-button.view';
import { userButtonBusyKeys, 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`. 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 & {}))[];
+}
+
/**
* Everything `` takes: where its profile surfaces open (`UserButtonControllerOptions`),
- * what the trigger shows (`UserButtonTriggerProps`), and the app's own rows at the foot of the menu
- * (`UserButtonMenuProps`).
+ * what the trigger shows (`UserButtonTriggerProps`), the app's own rows at the foot of the menu
+ * (`UserButtonMenuProps`), and the profile it opens (`UserButtonUserProfileProps`).
*/
export type UserButtonProps = UserButtonControllerOptions &
UserButtonTriggerProps &
UserButtonMenuProps &
- Pick;
+ UserButtonModeProps & {
+ userProfileProps?: UserButtonUserProfileProps;
+ };
/**
* The signed-in user's avatar, and the menu behind it: switch organization, switch or add an
@@ -36,9 +57,11 @@ export type UserButtonProps = UserButtonControllerOptions &
* ```
*
* @example
- * `modePriority` picks which switcher the menu leads with — in its header, and in the trigger beside
- * the avatar. The other one is still listed.
+ * `mode` narrows the menu to one switcher, and `modePriority` picks which one a combined menu leads
+ * with — in its header, and in the trigger beside the avatar. The other one is still listed.
* ```tsx
+ *
+ *
*
* ```
*
@@ -55,10 +78,14 @@ export type UserButtonProps = UserButtonControllerOptions &
* ```
*
* @example
- * `customMenuItems` adds your own rows to the foot of the menu, each one either an `onClick` action
- * or an `href` link, and `menuItemOrder` names the order the foot's rows run in.
+ * `customPages` adds your own pages to the profile this button opens; `customMenuItems` adds your
+ * own rows to the foot of the menu, each one either an `onClick` action or an `href` link.
* ```tsx
* , content: }],
+ * pageOrder: ['account', 'usage', 'security'],
+ * }}
* customMenuItems={[
* { id: 'docs', label: 'Documentation', icon: , href: 'https://example.com/docs' },
* { id: 'support', label: 'Contact support', icon: , onClick: () => openSupportChat() },
@@ -68,24 +95,49 @@ export type UserButtonProps = UserButtonControllerOptions &
* ```
*/
export function UserButton(props: UserButtonProps = {}): ReactElement | null {
- const { renderTriggerLabel, renderTriggerBadge, modePriority, customMenuItems, menuItemOrder, ...options } = props;
- const controller = useUserButtonController(options);
- const [open, setOpen] = useState(false);
- const [pendingKey, setPendingKey] = useState(null);
+ const {
+ renderTriggerLabel,
+ renderTriggerBadge,
+ mode: requestedMode,
+ modePriority,
+ userProfileProps,
+ customMenuItems,
+ menuItemOrder,
+ ...options
+ } = props;
+ // 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);
+ // The popover's open state and the one action in flight are the same flow: an action that ends the
+ // interaction closes the surface, so they settle together or not at all.
+ const [{ value, context }, send] = useMachine(userButtonMachine);
+
+ // Organizations off at the instance leaves nothing for an organization surface to lead with or
+ // list, so the button is the account's whatever mode asked for. clerk-js withholds its own
+ // `` at the mount boundary; nothing mounts this one, so the gate lives here.
+ const organizationsEnabled = useMosaicEnvironment()?.organizationSettings?.enabled ?? true;
+ const mode = organizationsEnabled ? requestedMode : 'user';
- // Hold the spinner off for quick actions and steady it once shown. Re-entry is still guarded on
- // the immediate `pendingKey`; only the view's feedback is delayed.
- const displayPendingKey = useSpinDelay(pendingKey);
+ // Every action here is a network round trip, so there is nothing to debounce and the click gets
+ // its spinner at once. The hook is still what steadies it, holding it up long enough to read.
+ const displayPendingKey = useSpinDelay(context.pendingKey, { delay: 0 });
// Nothing stands in for the button until Clerk answers: while it is loading, a signed-out visitor
// is indistinguishable from a session still resolving, so anything rendered here is a button
// promised to people who are never going to get one. `` is where an app that knows
// its own nav puts a placeholder.
if (controller.status !== 'ready') {
- return null;
+ return <>{portals}>;
}
- const close = () => setOpen(false);
+ const close = () => send({ type: 'CLOSE' });
// A custom action is the app's to run, and whatever it opens takes over from here, so the popover
// goes with it. A link navigates away on its own.
@@ -101,27 +153,36 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null {
: item,
);
- // Wraps a one-shot callback: block re-entry while busy, key the in-flight action for the view, and
- // always clear busy so a rejection cannot leave the UI hanging. Only an action that ends the
- // interaction closes the surface; the rest resolve into a popover that re-renders around the
- // result, so you can see what you just did.
+ // Hands a one-shot callback to the machine, keyed by the affordance that owns it and carrying the
+ // controller to freeze on. Re-entry, clearing busy, and closing on success are all the machine's.
const runAction = (
keyFor: (...args: Args) => string,
fn: ((...args: Args) => void | Promise) | undefined,
closeOnSuccess = false,
) =>
fn
- ? (...args: Args) => {
- if (pendingKey) {
- return;
- }
- setPendingKey(keyFor(...args));
- void Promise.resolve(fn(...args))
- .then(closeOnSuccess ? close : () => {}, () => {})
- .finally(() => setPendingKey(null));
+ ? (...args: Args) =>
+ send({
+ type: 'RUN',
+ key: keyFor(...args),
+ frozen: controller,
+ run: async () => fn(...args),
+ closeOnSuccess,
+ })
+ : undefined;
+
+ // A modal or another page takes over from here, so there is nothing left for the popover to show;
+ // left up, it would sit over the very surface it just opened.
+ const handOff = (fn: (() => void) | undefined) =>
+ fn
+ ? () => {
+ close();
+ fn();
}
: undefined;
+ // Rendering the controller the action froze on holds the popup still while it runs; the result
+ // lands in one step when it settles. See `frozen` in the machine for why.
const {
status: _status,
onSelectOrganization,
@@ -130,26 +191,40 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null {
onSignOutAll,
onAcceptSuggestion,
onAcceptInvitation,
+ onManageAccount,
+ onManageOrganization,
+ onInviteMembers,
+ onCreateOrganization,
+ onAddAccount,
...data
- } = controller;
+ } = context.frozen ?? controller;
return (
-
+ <>
+ send(next ? { type: 'OPEN' } : { type: 'CLOSE' })}
+ pendingKey={displayPendingKey}
+ onSelectOrganization={runAction(userButtonBusyKeys.selectOrganization, onSelectOrganization, true)}
+ onSwitchSession={runAction(userButtonBusyKeys.switchSession, onSwitchSession)}
+ onSignOutSession={runAction(userButtonBusyKeys.signOutSession, onSignOutSession)}
+ onSignOutAll={runAction(userButtonBusyKeys.signOutAll, onSignOutAll)}
+ onAcceptSuggestion={runAction(userButtonBusyKeys.acceptSuggestion, onAcceptSuggestion)}
+ onAcceptInvitation={runAction(userButtonBusyKeys.acceptInvitation, onAcceptInvitation)}
+ onManageAccount={handOff(onManageAccount)}
+ onManageOrganization={handOff(onManageOrganization)}
+ onInviteMembers={handOff(onInviteMembers)}
+ onCreateOrganization={handOff(onCreateOrganization)}
+ onAddAccount={handOff(onAddAccount)}
+ />
+ {portals}
+ >
);
}