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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-custom-pages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
9 changes: 8 additions & 1 deletion packages/nextjs/src/experimental/mosaic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
9 changes: 8 additions & 1 deletion packages/react/src/experimental/mosaic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
212 changes: 212 additions & 0 deletions packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
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<CustomPagesOptions>) {
const { customPages, portals } = useCustomPages({ items, order, builtInPages });
emitted = customPages;
return <div data-testid='host'>{portals}</div>;
}

const terms: CustomProfileItem = {
label: 'Terms',
path: 'terms',
icon: <span>terms icon</span>,
content: <p>Terms body</p>,
};

const docs: CustomProfileItem = {
label: 'Docs',
path: 'docs',
href: 'https://clerk.com/docs',
icon: <span>docs icon</span>,
};

beforeEach(() => {
emitted = undefined;
});

describe('useCustomPages', () => {
it('sends nothing when there are no custom pages', () => {
render(<Harness />);

expect(emitted).toBeUndefined();
expect(screen.getByTestId('host')).toBeEmptyDOMElement();
});

it('sends a page as its path and a link as its href', () => {
render(<Harness items={[terms, docs]} />);

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(<Harness items={[terms, docs]} />);

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(<Harness items={[{ label: 'Terms', path: 'terms', content: <p>Terms body</p> }]} />);

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(<Harness items={[terms]} />);

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(<Harness items={[terms]} />);

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: <p>Help body</p> };
render(<Harness items={[terms, help]} />);

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(<Harness items={[terms]} />);

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(<Harness items={[terms]} />);
const el = mountInto(emitted?.[0].mount);

rerender(<Harness items={[{ ...terms, content: <p>Revised terms</p> }]} />);

expect(within(el).getByText('Revised terms')).toBeInTheDocument();
});

describe('order', () => {
it('leaves the built-in pages alone when no order is given', () => {
render(<Harness items={[terms, docs]} />);

expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']);
});

it('sends the pages in the order it was given', () => {
render(
<Harness
items={[terms, docs]}
order={['security', 'terms', 'account', 'docs']}
/>,
);

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(<Harness order={['security', 'account']} />);

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(
<Harness
items={[terms, docs]}
builtInPages={['account', 'security', 'billing']}
order={['terms']}
/>,
);

expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security', 'billing', 'Docs']);
});

it('drops an id that belongs to no page', () => {
render(
<Harness
items={[terms]}
order={['billing', 'terms']}
/>,
);

expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security']);
});

it('sends a page once even when the order names it twice', () => {
render(<Harness order={['security', 'account', 'security']} />);

expect(emitted?.map(page => page.label)).toEqual(['security', 'account']);
});

it('renders a reordered page into the element clerk-js hands back', () => {
render(
<Harness
items={[terms]}
order={['account', 'terms']}
/>,
);

const el = mountInto(emitted?.[1].mount);

expect(within(el).getByText('Terms body')).toBeInTheDocument();
});
});
});
142 changes: 142 additions & 0 deletions packages/ui/src/mosaic/hooks/useCustomPages.tsx
Original file line number Diff line number Diff line change
@@ -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<string, CustomProfileItem>,
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<string, HTMLDivElement>, 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<ReadonlyMap<string, HTMLDivElement>>(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 };
}
Loading
Loading