Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5c977ab
test(ui): cover the connected UserButton end to end
alexcarpenter Aug 3, 2026
2499c68
feat(ui): close the UserButton popover only when a workspace is picked
alexcarpenter Aug 3, 2026
c6286c4
test(ui): query the account menu trigger as a button
alexcarpenter Aug 3, 2026
118fb3a
refactor(ui): migrate the Mosaic Spinner to StyleX and give it a sm size
alexcarpenter Aug 3, 2026
3aa069d
feat(ui): name the active workspace in the UserButton trigger
alexcarpenter Aug 3, 2026
daca5a5
feat(ui): let combined UserButton lead with the organization or the a…
alexcarpenter Aug 3, 2026
de3217f
test(ui): follow the UserButton active-organization contract in the c…
alexcarpenter Aug 4, 2026
cd06102
test(ui): carry organizationMemberships on the connected UserButton u…
alexcarpenter Aug 4, 2026
0e7feff
fix(ui): hold the Mosaic UserButton surface still while an action runs
alexcarpenter Aug 4, 2026
c049bcd
test(ui): cover the UserButton profile modals end to end
alexcarpenter Aug 5, 2026
31b9e4e
feat(ui): close the UserButton popover behind whatever it opens
alexcarpenter Aug 5, 2026
9d89cdd
test(ui): cover the UserButton personal workspace row end to end
alexcarpenter Aug 5, 2026
2d75e9f
test(ui): cover the named UserButton personal row end to end
alexcarpenter Aug 5, 2026
8941c5f
test(ui): move UserButton rendering cases down to the view test
alexcarpenter Aug 4, 2026
28c35f2
feat(ui): forward mode to the connected UserButton
alexcarpenter Aug 4, 2026
b2894fa
test(ui): cover the UserButton create-organization modal end to end
alexcarpenter Aug 5, 2026
d85c41e
test(ui): assert Invite opens the InviteMembers modal
alexcarpenter Aug 5, 2026
b42ac2f
test(ui): identify Alice's account row by her username
alexcarpenter Aug 5, 2026
549e2f8
test(ui): follow the join button's in-place spinner
alexcarpenter Aug 6, 2026
f80b724
test(ui): cover a custom menu item end to end
alexcarpenter Aug 6, 2026
bf736e9
fix(ui): make UserButton account-only where organizations are disabled
alexcarpenter Aug 6, 2026
f031a1b
refactor(ui): drive the Mosaic UserButton popover from a state machine
alexcarpenter Aug 7, 2026
14d3799
refactor(ui): drop comments the integration assertions already carry
alexcarpenter Aug 7, 2026
63a181b
refactor(ui): name the organization mode in full on the connected button
alexcarpenter Aug 7, 2026
5f4b502
feat(ui): add custom UserProfile pages to the Mosaic UserButton
alexcarpenter Aug 7, 2026
ba56e05
chore(repo): consolidate the branch's changesets into one
alexcarpenter Aug 7, 2026
8b0d937
refactor(ui): colocate the user button's page hooks with the button
alexcarpenter Aug 10, 2026
27b86e6
fix(ui): drop the Mosaic UserButton spinner hold when the action clos…
alexcarpenter Aug 10, 2026
e53536a
test(ui): follow the user button rows' aria-disabled through the inte…
alexcarpenter Aug 10, 2026
b9b4b32
Merge remote-tracking branch 'origin/carp/account-button-controller' …
alexcarpenter Aug 12, 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
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
10 changes: 8 additions & 2 deletions packages/ui/src/mosaic/hooks/useSpinDelay.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,11 +25,17 @@ export function useSpinDelay<T>(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);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type * as SharedReact from '@clerk/shared/react';
import type { CustomPage } from '@clerk/shared/types';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

Expand Down Expand Up @@ -183,8 +184,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 <output data-testid='status'>{c.status}</output>;
}
Expand Down Expand Up @@ -633,6 +634,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(<Harness customPages={customPages} />);

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', () => {
Expand Down
Loading
Loading