Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4d3af5c
test(ui): cover the connected UserButton end to end
alexcarpenter Aug 3, 2026
0044927
feat(ui): close the UserButton popover only when a workspace is picked
alexcarpenter Aug 3, 2026
638fed7
test(ui): query the account menu trigger as a button
alexcarpenter Aug 3, 2026
b7a96ed
refactor(ui): migrate the Mosaic Spinner to StyleX and give it a sm size
alexcarpenter Aug 3, 2026
c620bff
feat(ui): name the active workspace in the UserButton trigger
alexcarpenter Aug 3, 2026
440acf1
feat(ui): let combined UserButton lead with the organization or the a…
alexcarpenter Aug 3, 2026
c550253
test(ui): follow the UserButton active-organization contract in the c…
alexcarpenter Aug 4, 2026
4b51890
test(ui): carry organizationMemberships on the connected UserButton u…
alexcarpenter Aug 4, 2026
e8a76a5
fix(ui): hold the Mosaic UserButton surface still while an action runs
alexcarpenter Aug 4, 2026
bbc6f47
test(ui): cover the UserButton profile modals end to end
alexcarpenter Aug 5, 2026
84c867c
feat(ui): close the UserButton popover behind whatever it opens
alexcarpenter Aug 5, 2026
541a577
test(ui): cover the UserButton personal workspace row end to end
alexcarpenter Aug 5, 2026
09a9ef0
test(ui): cover the named UserButton personal row end to end
alexcarpenter Aug 5, 2026
0d7d82b
test(ui): move UserButton rendering cases down to the view test
alexcarpenter Aug 4, 2026
898aa75
feat(ui): forward mode to the connected UserButton
alexcarpenter Aug 4, 2026
a789b74
test(ui): cover the UserButton create-organization modal end to end
alexcarpenter Aug 5, 2026
b172109
test(ui): assert Invite opens the InviteMembers modal
alexcarpenter Aug 5, 2026
b99e8d7
test(ui): identify Alice's account row by her username
alexcarpenter Aug 5, 2026
7702921
test(ui): follow the join button's in-place spinner
alexcarpenter Aug 6, 2026
c1fe582
test(ui): cover a custom menu item end to end
alexcarpenter Aug 6, 2026
fa64d9d
fix(ui): make UserButton account-only where organizations are disabled
alexcarpenter Aug 6, 2026
a1c85a4
refactor(ui): drive the Mosaic UserButton popover from a state machine
alexcarpenter Aug 7, 2026
cc5c149
refactor(ui): drop comments the integration assertions already carry
alexcarpenter Aug 7, 2026
9effd1d
refactor(ui): name the organization mode in full on the connected button
alexcarpenter Aug 7, 2026
7372e46
feat(ui): add custom UserProfile pages to the Mosaic UserButton
alexcarpenter Aug 7, 2026
fb1eb38
chore(repo): consolidate the branch's changesets into one
alexcarpenter Aug 7, 2026
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-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
18 changes: 14 additions & 4 deletions packages/ui/src/mosaic/components/button/submit-button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
spinDelay={{ delay: 1000 }}
>
Save
</SubmitButton>,
);
const hidden = atoms(spinner());

advance(0);
rerender(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
>
Save
</SubmitButton>,
);

expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

Expand Down
208 changes: 208 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,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<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();
});

// 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(<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']);
});

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

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(
<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();
});
});
});
21 changes: 21 additions & 0 deletions packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));
Expand Down
Loading
Loading