Skip to content
Closed
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
9e77008
test(ui): cover the connected UserButton end to end
alexcarpenter Aug 3, 2026
f081eb3
feat(ui): close the UserButton popover only when a workspace is picked
alexcarpenter Aug 3, 2026
9ff3048
test(ui): query the account menu trigger as a button
alexcarpenter Aug 3, 2026
73e44b9
refactor(ui): migrate the Mosaic Spinner to StyleX and give it a sm size
alexcarpenter Aug 3, 2026
d997d2a
feat(ui): name the active workspace in the UserButton trigger
alexcarpenter Aug 3, 2026
400451f
feat(ui): let combined UserButton lead with the organization or the a…
alexcarpenter Aug 3, 2026
f15f6f6
test(ui): follow the UserButton active-organization contract in the c…
alexcarpenter Aug 4, 2026
6de655d
test(ui): carry organizationMemberships on the connected UserButton u…
alexcarpenter Aug 4, 2026
60d2261
fix(ui): hold the Mosaic UserButton surface still while an action runs
alexcarpenter Aug 4, 2026
b4af3a4
test(ui): cover the UserButton profile modals end to end
alexcarpenter Aug 5, 2026
6751b12
feat(ui): close the UserButton popover behind whatever it opens
alexcarpenter Aug 5, 2026
09139d7
test(ui): cover the UserButton personal workspace row end to end
alexcarpenter Aug 5, 2026
4ad031b
test(ui): cover the named UserButton personal row end to end
alexcarpenter Aug 5, 2026
6a1842b
test(ui): move UserButton rendering cases down to the view test
alexcarpenter Aug 4, 2026
8822ce5
feat(ui): forward mode to the connected UserButton
alexcarpenter Aug 4, 2026
a0c4c8b
test(ui): cover the UserButton create-organization modal end to end
alexcarpenter Aug 5, 2026
df762fb
test(ui): assert Invite opens the InviteMembers modal
alexcarpenter Aug 5, 2026
9a3ba9a
test(ui): identify Alice's account row by her username
alexcarpenter Aug 5, 2026
dd0893c
test(ui): follow the join button's in-place spinner
alexcarpenter Aug 6, 2026
0716089
test(ui): cover a custom menu item end to end
alexcarpenter Aug 6, 2026
abc6347
fix(ui): make UserButton account-only where organizations are disabled
alexcarpenter Aug 6, 2026
c132c7b
refactor(ui): drive the Mosaic UserButton popover from a state machine
alexcarpenter Aug 7, 2026
fb0a499
refactor(ui): drop comments the integration assertions already carry
alexcarpenter Aug 7, 2026
e48835a
refactor(ui): name the organization mode in full on the connected button
alexcarpenter Aug 7, 2026
3bfa70d
feat(ui): add custom UserProfile pages to the Mosaic UserButton
alexcarpenter Aug 7, 2026
5565f1c
chore(repo): consolidate the branch's changesets into one
alexcarpenter Aug 7, 2026
977b36a
refactor(ui): colocate the user button's page hooks with the button
alexcarpenter Aug 10, 2026
ea61af1
fix(ui): drop the Mosaic UserButton spinner hold when the action clos…
alexcarpenter Aug 10, 2026
83a3958
test(ui): follow the user button rows' aria-disabled through the inte…
alexcarpenter Aug 10, 2026
30adecc
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-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