Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
2e8e95a
feat(ui): add UserButton controller
alexcarpenter Jul 16, 2026
6b3bd6f
feat(ui): add loading and busy states to UserButton
alexcarpenter Aug 3, 2026
9ac7574
feat(ui): close the UserButton popover only when a workspace is picked
alexcarpenter Aug 3, 2026
668dbf5
feat(ui): name the active workspace in the UserButton trigger
alexcarpenter Aug 3, 2026
594c60f
refactor(ui): split the UserButton trigger label into two props
alexcarpenter Aug 3, 2026
426181e
feat(ui): let combined UserButton lead with the organization or the a…
alexcarpenter Aug 3, 2026
9274449
feat(ui): hand the UserButton its active organization and list loadin…
alexcarpenter Aug 4, 2026
7b94f02
fix(ui): answer hasOrganizations from the user resource, before the l…
alexcarpenter Aug 4, 2026
bf99bd9
feat(ui): open the UserButton profiles as modals, routable by URL
alexcarpenter Aug 4, 2026
a4bbae0
feat(ui): portal the UserButton profile modals
alexcarpenter Aug 5, 2026
4e91d5d
feat(ui): offer the personal workspace from the UserButton controller
alexcarpenter Aug 5, 2026
5029a31
feat(ui): route the UserButton personal row with afterSelectPersonalUrl
alexcarpenter Aug 5, 2026
e89ca65
fix(ui): withhold the UserButton personal row where an organization i…
alexcarpenter Aug 4, 2026
461f55e
feat(ui): let an app hide the UserButton personal workspace
alexcarpenter Aug 4, 2026
2f0445a
fix(ui): gate UserButton create-organization on the user's permission
alexcarpenter Aug 4, 2026
728bc01
feat(ui): open the create-organization modal from the UserButton
alexcarpenter Aug 4, 2026
bf065fe
feat(ui): open the invite-members modal from the Mosaic UserButton
alexcarpenter Aug 5, 2026
588884e
fix(ui): identify the UserButton account the way Clerk does
alexcarpenter Aug 5, 2026
f1fd7aa
feat(ui): forward custom menu items through the connected UserButton
alexcarpenter Aug 6, 2026
453a479
docs(ui): document the connected UserButton for the apps using it
alexcarpenter Aug 6, 2026
846f499
fix(ui): render nothing from UserButton until Clerk answers
alexcarpenter Aug 6, 2026
e30d0d1
refactor(ui): drop comments the controller test names already carry
alexcarpenter Aug 7, 2026
db07d5c
refactor(ui): follow the renderTriggerBadge rename through the connec…
alexcarpenter Aug 7, 2026
2a96d1d
fix(ui): wait for the environment before the user button reports ready
alexcarpenter Aug 10, 2026
85e5cee
fix(ui): read the branding off the instance in the Mosaic UserButton
alexcarpenter Aug 10, 2026
853af2d
fix(ui): follow the renderBranding rename through the UserButton cont…
alexcarpenter Aug 12, 2026
ec36261
docs(ui): trim the Mosaic UserButton comments to the non-obvious
alexcarpenter Aug 13, 2026
4ce9b5b
fix(ui): route a switched Mosaic UserButton session to its pending task
alexcarpenter Aug 13, 2026
354c697
feat(ui): add a fallback prop to the Mosaic UserButton
alexcarpenter Aug 13, 2026
52d46a9
test(ui): cover the connected UserButton end to end
alexcarpenter Aug 3, 2026
1abd31b
feat(ui): close the UserButton popover only when a workspace is picked
alexcarpenter Aug 3, 2026
3fdbc56
test(ui): query the account menu trigger as a button
alexcarpenter Aug 3, 2026
26e7609
refactor(ui): migrate the Mosaic Spinner to StyleX and give it a sm size
alexcarpenter Aug 3, 2026
d1ad37d
feat(ui): name the active workspace in the UserButton trigger
alexcarpenter Aug 3, 2026
7b02c74
feat(ui): let combined UserButton lead with the organization or the a…
alexcarpenter Aug 3, 2026
42213a2
test(ui): follow the UserButton active-organization contract in the c…
alexcarpenter Aug 4, 2026
a8a5446
test(ui): carry organizationMemberships on the connected UserButton u…
alexcarpenter Aug 4, 2026
f3c0355
fix(ui): hold the Mosaic UserButton surface still while an action runs
alexcarpenter Aug 4, 2026
46c792b
test(ui): cover the UserButton profile modals end to end
alexcarpenter Aug 5, 2026
5c678d9
feat(ui): close the UserButton popover behind whatever it opens
alexcarpenter Aug 5, 2026
48f5c8d
test(ui): cover the UserButton personal workspace row end to end
alexcarpenter Aug 5, 2026
1e5768b
test(ui): cover the named UserButton personal row end to end
alexcarpenter Aug 5, 2026
828a586
test(ui): move UserButton rendering cases down to the view test
alexcarpenter Aug 4, 2026
63c4cc9
feat(ui): forward mode to the connected UserButton
alexcarpenter Aug 4, 2026
a4a55df
test(ui): cover the UserButton create-organization modal end to end
alexcarpenter Aug 5, 2026
ae17882
test(ui): assert Invite opens the InviteMembers modal
alexcarpenter Aug 5, 2026
0e6b0a5
test(ui): identify Alice's account row by her username
alexcarpenter Aug 5, 2026
52b443e
test(ui): follow the join button's in-place spinner
alexcarpenter Aug 6, 2026
c3b8177
test(ui): cover a custom menu item end to end
alexcarpenter Aug 6, 2026
e9cb06e
fix(ui): make UserButton account-only where organizations are disabled
alexcarpenter Aug 6, 2026
857c262
refactor(ui): drive the Mosaic UserButton popover from a state machine
alexcarpenter Aug 7, 2026
0e61db8
refactor(ui): drop comments the integration assertions already carry
alexcarpenter Aug 7, 2026
4c27315
refactor(ui): name the organization mode in full on the connected button
alexcarpenter Aug 7, 2026
921d746
feat(ui): add custom UserProfile pages to the Mosaic UserButton
alexcarpenter Aug 7, 2026
e807555
chore(repo): consolidate the branch's changesets into one
alexcarpenter Aug 7, 2026
62060ce
refactor(ui): colocate the user button's page hooks with the button
alexcarpenter Aug 10, 2026
aedfdbd
fix(ui): drop the Mosaic UserButton spinner hold when the action clos…
alexcarpenter Aug 10, 2026
ed9f99d
test(ui): follow the user button rows' aria-disabled through the inte…
alexcarpenter Aug 10, 2026
39311a9
refactor(ui): name the Mosaic UserButton layers for their MVC roles v…
Ephem Aug 24, 2026
cc37bea
Prevent UserButton opening again on sign-in after sign-out
Ephem Aug 26, 2026
968b7e7
Rename frozen -> frozenModel
Ephem Aug 26, 2026
45adf07
Resolve controller status from the frozen model to avoid reverting to…
Ephem Aug 27, 2026
0fbcd81
Update fallback jsdoc comment
Ephem Aug 27, 2026
13ddc58
Await suggestion/invitation revalidation
Ephem Aug 27, 2026
0fa16fe
Add afterSwitchSessionUrl prop
Ephem Aug 27, 2026
6476273
Handle "No organization selected"
Ephem Aug 27, 2026
025cfe3
Add comment with possibly missing props compared to old UserButton
Ephem Aug 27, 2026
dd14d63
Fix swingset formatting
Ephem Aug 27, 2026
c20f0dc
Refactor open/closed state to be a machine context value instead of a…
Ephem Aug 27, 2026
f4e55a3
Format
Ephem Aug 27, 2026
7e5061c
Fix tests
Ephem Aug 27, 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-controller.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
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 @@
---
---
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import { useClerk } from './useClerk';
*
* @internal
*/
export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'useOrganizationList') {
export function useAttemptToEnableOrganizations(
caller: 'useOrganization' | 'useOrganizationList',
{ enabled = true }: { enabled?: boolean } = {},
) {
const clerk = useClerk();
const hasAttempted = useRef(false);

useEffect(() => {
// Guard to not run this effect twice on Clerk resource update
if (hasAttempted.current) {
if (!enabled || hasAttempted.current) {
return;
}

Expand All @@ -23,5 +26,5 @@ export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'use
for: 'organizations',
caller,
});
}, [clerk, caller]);
}, [clerk, caller, enabled]);
}
11 changes: 10 additions & 1 deletion packages/shared/src/react/hooks/useOrganization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ export type UseOrganizationParams = {
* </ul>
*/
invitations?: true | PaginatedHookConfig<GetInvitationsParams>;
/**
* Skip the development prompt that offers to enable Organizations.
*
* @internal
*/
__internal_skipAttemptToEnableOrganizations?: boolean;
};

/**
Expand Down Expand Up @@ -275,10 +281,13 @@ export function useOrganization<T extends UseOrganizationParams>(params?: T): Us
membershipRequests: membershipRequestsListParams,
memberships: membersListParams,
invitations: invitationsListParams,
__internal_skipAttemptToEnableOrganizations,
} = params || {};

useAssertWrappedByClerkProvider('useOrganization');
useAttemptToEnableOrganizations('useOrganization');
useAttemptToEnableOrganizations('useOrganization', {
enabled: !__internal_skipAttemptToEnableOrganizations,
});

const organization = useOrganizationBase();
const session = useSessionBase();
Expand Down
5 changes: 4 additions & 1 deletion packages/shared/src/react/hooks/useOrganizationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,10 @@ export function useOrganizationList<T extends UseOrganizationListParams>(params?
const { userMemberships, userInvitations, userSuggestions } = params || {};

useAssertWrappedByClerkProvider('useOrganizationList');
useAttemptToEnableOrganizations('useOrganizationList');
// No list keys means this call is not using Organizations; the prompt is for the lists.
useAttemptToEnableOrganizations('useOrganizationList', {
enabled: userMemberships !== undefined || userInvitations !== undefined || userSuggestions !== undefined,
});

const userMembershipsSafeValues = useWithSafeValues(userMemberships, {
initialPage: 1,
Expand Down
14 changes: 13 additions & 1 deletion packages/swingset/src/stories/user-button.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Exports are flat (not `UserButton.Trigger`) so each part declares its own `'use
## Trigger

The active workspace's avatar and what it is called: the org and its plan wherever one heads the
trigger, the account otherwise.
trigger, no selection when personal is hidden and none is active, the account otherwise.

`renderTriggerLabel={false}` leaves the avatar alone.

Expand Down Expand Up @@ -164,6 +164,18 @@ accounts" ever meant here.
storyModule={UserButtonStories}
/>

## No organization selected

`hidePersonal` withholds the personal workspace — the instance does this when it forces an
organization, or the app does it itself. With no org active either, the lead is not the account:
trigger and header say **No organization selected**, the mark is square, and the gear is **Manage
account**.

<Story
name='NoOrganizationSelected'
storyModule={UserButtonStories}
/>

## Menu items

`customMenuItems` adds the app's own rows to the foot of the popup, ahead of Clerk's own. A row with
Expand Down
28 changes: 26 additions & 2 deletions packages/swingset/src/stories/user-button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,19 @@ const LATENCY_MS = 800;
* The actions that would navigate somewhere in a real app (Manage, Invite, Create organization, Add
* account) have nowhere to go here, so they only close the popover.
*/
function usePrototype(): Omit<UserButtonProps, 'mode'> {
function usePrototype({
hidePersonal = false,
startWithoutOrganization = false,
}: {
hidePersonal?: boolean;
startWithoutOrganization?: boolean;
} = {}): Omit<UserButtonProps, 'mode'> {
const [open, setOpen] = useState(false);
const [accounts, setAccounts] = useState(initialAccounts);
const [accounts, setAccounts] = useState(() =>
startWithoutOrganization
? initialAccounts.map(a => (a.session.sessionId === colin.sessionId ? { ...a, activeOrganizationId: null } : a))
: initialAccounts,
);
const [activeSessionId, setActiveSessionId] = useState(colin.sessionId);
const [pendingKey, setPendingKey] = useState<string | null>(null);

Expand Down Expand Up @@ -196,6 +206,7 @@ function usePrototype(): Omit<UserButtonProps, 'mode'> {
suggestions: account.suggestions,
invitations: account.invitations,
additionalSessions: accounts.filter(a => a.session.sessionId !== activeSessionId).map(a => a.session),
hidePersonal,
// Selecting an organization only ever acts on the active account, and is the one action that
// closes the surface behind it.
onSelectOrganization: organizationId =>
Expand Down Expand Up @@ -300,6 +311,19 @@ export function User(_args: Record<string, unknown>) {
);
}

export function NoOrganizationSelected(_args: Record<string, unknown>) {
const prototype = usePrototype({ hidePersonal: true, startWithoutOrganization: true });

// Personal is withheld and nothing is active, so the lead is no selection — not the account.
// Picking an organization leaves it.
return (
<UserButtonView
{...prototype}
mode='combined'
/>
);
}

export function SingleSession(_args: Record<string, unknown>) {
const prototype = usePrototype();

Expand Down
10 changes: 7 additions & 3 deletions packages/ui/src/hooks/useOrganizationListInView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ import { useInView } from './useInView';

/**
* @internal
*
* `enabled` withholds the list params so the three requests do not start. Defaults on.
*/
export const useOrganizationListInView = () => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(organizationListParams);
export const useOrganizationListInView = ({ enabled = true }: { enabled?: boolean } = {}) => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(
enabled ? organizationListParams : undefined,
);

const { ref } = useInView({
threshold: 0,
onChange: inView => {
if (!inView) {
if (!enabled || !inView) {
return;
}
if (userMemberships.hasNextPage) {
Expand Down
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 @@ -317,19 +317,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
Loading
Loading