Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
9930d6d
feat(ui): add UserButton controller
alexcarpenter Jul 16, 2026
b258e41
feat(ui): add loading and busy states to UserButton
alexcarpenter Aug 3, 2026
02f18c7
feat(ui): close the UserButton popover only when a workspace is picked
alexcarpenter Aug 3, 2026
84e475a
feat(ui): name the active workspace in the UserButton trigger
alexcarpenter Aug 3, 2026
d565d66
refactor(ui): split the UserButton trigger label into two props
alexcarpenter Aug 3, 2026
0daf79b
feat(ui): let combined UserButton lead with the organization or the a…
alexcarpenter Aug 3, 2026
6be8bd1
feat(ui): hand the UserButton its active organization and list loadin…
alexcarpenter Aug 4, 2026
80104ad
fix(ui): answer hasOrganizations from the user resource, before the l…
alexcarpenter Aug 4, 2026
0eefec4
feat(ui): open the UserButton profiles as modals, routable by URL
alexcarpenter Aug 4, 2026
84b2e53
feat(ui): portal the UserButton profile modals
alexcarpenter Aug 5, 2026
aa349c4
feat(ui): offer the personal workspace from the UserButton controller
alexcarpenter Aug 5, 2026
04ada04
feat(ui): route the UserButton personal row with afterSelectPersonalUrl
alexcarpenter Aug 5, 2026
4d6e6e5
fix(ui): withhold the UserButton personal row where an organization i…
alexcarpenter Aug 4, 2026
54f6652
feat(ui): let an app hide the UserButton personal workspace
alexcarpenter Aug 4, 2026
4f39ea6
fix(ui): gate UserButton create-organization on the user's permission
alexcarpenter Aug 4, 2026
f8f5005
feat(ui): open the create-organization modal from the UserButton
alexcarpenter Aug 4, 2026
c9d18e0
feat(ui): open the invite-members modal from the Mosaic UserButton
alexcarpenter Aug 5, 2026
663b460
fix(ui): identify the UserButton account the way Clerk does
alexcarpenter Aug 5, 2026
9cf6358
feat(ui): forward custom menu items through the connected UserButton
alexcarpenter Aug 6, 2026
92e6a7f
docs(ui): document the connected UserButton for the apps using it
alexcarpenter Aug 6, 2026
f60a9c0
fix(ui): render nothing from UserButton until Clerk answers
alexcarpenter Aug 6, 2026
9f5186c
refactor(ui): drop comments the controller test names already carry
alexcarpenter Aug 7, 2026
ab373e5
refactor(ui): follow the renderTriggerBadge rename through the connec…
alexcarpenter Aug 7, 2026
532d417
fix(ui): wait for the environment before the user button reports ready
alexcarpenter Aug 10, 2026
9431d8e
fix(ui): read the branding off the instance in the Mosaic UserButton
alexcarpenter Aug 10, 2026
8aa9600
fix(ui): follow the renderBranding rename through the UserButton cont…
alexcarpenter Aug 12, 2026
58c8a44
docs(ui): trim the Mosaic UserButton comments to the non-obvious
alexcarpenter Aug 13, 2026
18b4281
fix(ui): route a switched Mosaic UserButton session to its pending task
alexcarpenter Aug 13, 2026
7606085
feat(ui): add a fallback prop to the Mosaic UserButton
alexcarpenter Aug 13, 2026
45af0de
test(ui): cover the connected UserButton end to end
alexcarpenter Aug 3, 2026
79cdfd5
feat(ui): close the UserButton popover only when a workspace is picked
alexcarpenter Aug 3, 2026
ce3a1c3
test(ui): query the account menu trigger as a button
alexcarpenter Aug 3, 2026
e24be21
refactor(ui): migrate the Mosaic Spinner to StyleX and give it a sm size
alexcarpenter Aug 3, 2026
864b7be
feat(ui): name the active workspace in the UserButton trigger
alexcarpenter Aug 3, 2026
5dc89a7
feat(ui): let combined UserButton lead with the organization or the a…
alexcarpenter Aug 3, 2026
c444fde
test(ui): follow the UserButton active-organization contract in the c…
alexcarpenter Aug 4, 2026
99fd8a3
test(ui): carry organizationMemberships on the connected UserButton u…
alexcarpenter Aug 4, 2026
94d8042
fix(ui): hold the Mosaic UserButton surface still while an action runs
alexcarpenter Aug 4, 2026
10a5637
test(ui): cover the UserButton profile modals end to end
alexcarpenter Aug 5, 2026
80c81e6
feat(ui): close the UserButton popover behind whatever it opens
alexcarpenter Aug 5, 2026
460ee30
test(ui): cover the UserButton personal workspace row end to end
alexcarpenter Aug 5, 2026
033faac
test(ui): cover the named UserButton personal row end to end
alexcarpenter Aug 5, 2026
9750486
test(ui): move UserButton rendering cases down to the view test
alexcarpenter Aug 4, 2026
d0d11c3
feat(ui): forward mode to the connected UserButton
alexcarpenter Aug 4, 2026
57cf49f
test(ui): cover the UserButton create-organization modal end to end
alexcarpenter Aug 5, 2026
eb933c7
test(ui): assert Invite opens the InviteMembers modal
alexcarpenter Aug 5, 2026
71fac47
test(ui): identify Alice's account row by her username
alexcarpenter Aug 5, 2026
e28d7c0
test(ui): follow the join button's in-place spinner
alexcarpenter Aug 6, 2026
6edcd13
test(ui): cover a custom menu item end to end
alexcarpenter Aug 6, 2026
6527721
fix(ui): make UserButton account-only where organizations are disabled
alexcarpenter Aug 6, 2026
f279021
refactor(ui): drive the Mosaic UserButton popover from a state machine
alexcarpenter Aug 7, 2026
1907022
refactor(ui): drop comments the integration assertions already carry
alexcarpenter Aug 7, 2026
1fb4e14
refactor(ui): name the organization mode in full on the connected button
alexcarpenter Aug 7, 2026
dfc2ac0
feat(ui): add custom UserProfile pages to the Mosaic UserButton
alexcarpenter Aug 7, 2026
db59ee8
chore(repo): consolidate the branch's changesets into one
alexcarpenter Aug 7, 2026
3b40f00
refactor(ui): colocate the user button's page hooks with the button
alexcarpenter Aug 10, 2026
509f331
fix(ui): drop the Mosaic UserButton spinner hold when the action clos…
alexcarpenter Aug 10, 2026
45ded19
test(ui): follow the user button rows' aria-disabled through the inte…
alexcarpenter Aug 10, 2026
23d2212
refactor(ui): name the Mosaic UserButton layers for their MVC roles v…
Ephem Aug 24, 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
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 @@ -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
Loading
Loading