From 9bf07f427f7a9a8857e955b3012617b70eca0378 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 16 Jul 2026 17:42:45 -0400 Subject: [PATCH 1/7] feat(ui): add UserButton controller --- .../__tests__/user-button.controller.test.tsx | 419 ++++++++++++++++++ .../user-button/user-button.controller.tsx | 185 ++++++++ .../ui/src/mosaic/user-button/user-button.tsx | 41 ++ 3 files changed, 645 insertions(+) create mode 100644 packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx create mode 100644 packages/ui/src/mosaic/user-button/user-button.controller.tsx create mode 100644 packages/ui/src/mosaic/user-button/user-button.tsx diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx new file mode 100644 index 00000000000..f49af448b4c --- /dev/null +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx @@ -0,0 +1,419 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { UserButtonControllerOptions } from '../user-button.controller'; +import { useUserButtonController } from '../user-button.controller'; + +interface FakeUser { + id: string; + firstName: string | null; + lastName: string | null; + username: string | null; + primaryEmailAddress: { emailAddress: string } | null; + imageUrl: string; +} + +interface FakeSession { + id: string; + user: FakeUser; +} + +interface FakeList { + data: unknown[]; + count: number; + hasNextPage: boolean; + revalidate: ReturnType; +} + +let isUserLoaded: boolean; +let isSessionLoaded: boolean; +let isOrgLoaded: boolean; +let user: FakeUser | null; +let session: { id: string; checkAuthorization: ReturnType } | null; +let organization: { id: string } | null; +let userMemberships: FakeList; +let userInvitations: FakeList; +let userSuggestions: FakeList; +let signedInSessions: FakeSession[]; +let pagingRef: (element: HTMLElement | null) => void; +let singleSessionMode: boolean; + +let setActive: ReturnType; +let signOut: ReturnType; +let navigate: ReturnType; +let checkAuthorization: ReturnType; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ isLoaded: isUserLoaded, user }), + useSession: () => ({ isLoaded: isSessionLoaded, session }), + useOrganization: () => ({ isLoaded: isOrgLoaded, organization }), + useClerk: () => ({ + navigate, + setActive, + signOut, + buildUserProfileUrl: () => '/user-profile', + buildOrganizationProfileUrl: () => '/org-profile', + buildCreateOrganizationUrl: () => '/create-org', + buildSignInUrl: () => '/sign-in', + buildAfterSignOutUrl: () => '/after-sign-out', + buildAfterMultiSessionSingleSignOutUrl: () => '/after-single-sign-out', + client: { signedInSessions }, + __internal_environment: { + displayConfig: { afterSwitchSessionUrl: '/after-switch' }, + authConfig: { singleSessionMode }, + }, + }), + }; +}); + +// The controller reads its three paginated lists through the shared in-view helper, so the fetch +// boundary is stubbed there rather than at `useOrganizationList`. +vi.mock('../../../hooks/useOrganizationListInView', () => ({ + useOrganizationListInView: () => ({ userMemberships, userInvitations, userSuggestions, ref: pagingRef }), +})); + +function acceptable(id: string, orgId: string, orgName: string, status: 'pending' | 'accepted' = 'pending') { + return { + id, + status, + accept: vi.fn().mockResolvedValue(undefined), + publicOrganizationData: { id: orgId, name: orgName, imageUrl: '' }, + }; +} + +function membership(orgId: string, name: string, membersCount: number) { + return { organization: { id: orgId, name, imageUrl: '', membersCount } }; +} + +function list(data: unknown[], count: number, hasNextPage = false): FakeList { + return { data, count, hasNextPage, revalidate: vi.fn().mockResolvedValue(undefined) }; +} + +beforeEach(() => { + isUserLoaded = true; + isSessionLoaded = true; + isOrgLoaded = true; + user = { + id: 'user_1', + firstName: 'Alice', + lastName: 'Smith', + username: 'alice', + primaryEmailAddress: { emailAddress: 'alice@example.com' }, + imageUrl: 'https://img/alice', + }; + session = { id: 'sess_1', checkAuthorization: (checkAuthorization = vi.fn().mockReturnValue(true)) }; + organization = { id: 'org_1' }; + userMemberships = list([membership('org_1', 'Acme', 3), membership('org_9', 'Other', 1)], 2); + userInvitations = list([acceptable('inv_1', 'org_3', 'Gamma')], 1); + userSuggestions = list([acceptable('sug_1', 'org_2', 'Beta')], 1); + pagingRef = vi.fn(); + singleSessionMode = false; + signedInSessions = [ + { id: 'sess_1', user: user }, + { + id: 'sess_2', + user: { + id: 'user_2', + firstName: 'Bob', + lastName: 'Jones', + username: null, + primaryEmailAddress: { emailAddress: 'bob@example.com' }, + imageUrl: 'https://img/bob', + }, + }, + ]; + setActive = vi.fn().mockResolvedValue(undefined); + signOut = vi.fn().mockResolvedValue(undefined); + navigate = vi.fn().mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function Harness(options: UserButtonControllerOptions = {}) { + const c = useUserButtonController(options); + if (c.status !== 'ready') { + return {c.status}; + } + return ( +
+ {c.status} + {c.activeSession.name} + {c.activeSession.email} + {c.activeSession.sessionId} + {String(c.activeOrganizationId)} + {String(c.hasOrganizations)} + {c.additionalSessions.map(a => a.sessionId).join(',')} + {String(c.paging?.hasMore)} + {String(c.paging?.ref === pagingRef)} + {String(Boolean(c.onInviteMembers))} + {String(Boolean(c.onSignOutAll))} + {String(Boolean(c.onAddAccount))} + {JSON.stringify(c.memberships)} + {JSON.stringify(c.suggestions)} + {JSON.stringify(c.invitations)} + + + + + + + + + + + +
+ ); +} + +function memberships() { + return JSON.parse(screen.getByTestId('memberships').textContent ?? '[]'); +} + +describe('useUserButtonController', () => { + it('is loading until the user, session, and organization are all loaded', () => { + isUserLoaded = false; + const { rerender } = render(); + expect(screen.getByTestId('status')).toHaveTextContent('loading'); + + isUserLoaded = true; + isSessionLoaded = false; + rerender(); + expect(screen.getByTestId('status')).toHaveTextContent('loading'); + + isSessionLoaded = true; + isOrgLoaded = false; + rerender(); + expect(screen.getByTestId('status')).toHaveTextContent('loading'); + }); + + it('is hidden when loaded but there is no active user', () => { + user = null; + render(); + expect(screen.getByTestId('status')).toHaveTextContent('hidden'); + }); + + it('maps the active account and prefers first+last > username > email for the name', () => { + const { rerender } = render(); + expect(screen.getByTestId('status')).toHaveTextContent('ready'); + expect(screen.getByTestId('active-name')).toHaveTextContent('Alice Smith'); + expect(screen.getByTestId('active-email')).toHaveTextContent('alice@example.com'); + expect(screen.getByTestId('active-session')).toHaveTextContent('sess_1'); + + user = { ...(user as FakeUser), firstName: null, lastName: null }; + rerender(); + expect(screen.getByTestId('active-name')).toHaveTextContent('alice'); + + user = { ...user, username: null }; + rerender(); + expect(screen.getByTestId('active-name')).toHaveTextContent('alice@example.com'); + }); + + it('reflects the active organization id, and null in personal mode', () => { + const { rerender } = render(); + expect(screen.getByTestId('active-org')).toHaveTextContent('org_1'); + + organization = null; + rerender(); + expect(screen.getByTestId('active-org')).toHaveTextContent('null'); + }); + + it('derives hasOrganizations from the membership count, not the array length', () => { + userMemberships = list([membership('org_1', 'Acme', 3)], 0); + const { rerender } = render(); + expect(screen.getByTestId('has-orgs')).toHaveTextContent('false'); + + userMemberships = list([], 5); + rerender(); + expect(screen.getByTestId('has-orgs')).toHaveTextContent('true'); + }); + + it('carries only sessions in additionalSessions, excluding the active one', () => { + render(); + expect(screen.getByTestId('additional')).toHaveTextContent('sess_2'); + expect(screen.getByTestId('additional')).not.toHaveTextContent('sess_1'); + }); + + it('maps membership, suggestion, and invitation rows with the correct kind discriminants', () => { + render(); + + const rows = memberships(); + expect(rows[0]).toMatchObject({ kind: 'membership', organizationId: 'org_1', name: 'Acme', membersCount: 3 }); + + const suggestions = JSON.parse(screen.getByTestId('suggestions').textContent ?? '[]'); + expect(suggestions[0]).toMatchObject({ + kind: 'suggestion', + id: 'sug_1', + organizationId: 'org_2', + name: 'Beta', + status: 'pending', + }); + + const invitations = JSON.parse(screen.getByTestId('invitations').textContent ?? '[]'); + expect(invitations[0]).toMatchObject({ + kind: 'invitation', + id: 'inv_1', + organizationId: 'org_3', + organizationName: 'Gamma', + }); + }); + + it('reports more to page in when any of the three lists has a next page', () => { + const { rerender } = render(); + expect(screen.getByTestId('has-more')).toHaveTextContent('false'); + expect(screen.getByTestId('paging-ref')).toHaveTextContent('true'); + + userSuggestions = list([], 0, true); + rerender(); + expect(screen.getByTestId('has-more')).toHaveTextContent('true'); + }); + + it('offers inviting members only with the manage-memberships permission', () => { + const { rerender } = render(); + expect(screen.getByTestId('can-invite')).toHaveTextContent('true'); + expect(checkAuthorization).toHaveBeenCalledWith({ permission: 'org:sys_memberships:manage' }); + + checkAuthorization.mockReturnValue(false); + rerender(); + expect(screen.getByTestId('can-invite')).toHaveTextContent('false'); + }); + + it('selects an organization via setActive, with no redirect unless one is configured', () => { + const { rerender } = render(); + + fireEvent.click(screen.getByText('select-org')); + expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined }); + + rerender(); + fireEvent.click(screen.getByText('select-org')); + expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/orgs/org_9' }); + + rerender( `/o/${org.name}`} />); + fireEvent.click(screen.getByText('select-org')); + expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/o/Other' }); + }); + + it('switches sessions and routes each sign out to the URL that matches what is left', () => { + const { rerender } = render(); + + fireEvent.click(screen.getByText('switch')); + expect(setActive).toHaveBeenCalledWith(expect.objectContaining({ session: 'sess_2' })); + + // Another account stays signed in, so this is a single sign out, not a full one. + fireEvent.click(screen.getByText('sign-out-one')); + expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_2', redirectUrl: '/after-single-sign-out' }); + + fireEvent.click(screen.getByText('sign-out-all')); + expect(signOut).toHaveBeenCalledWith({ redirectUrl: '/after-sign-out' }); + + signedInSessions = signedInSessions.slice(0, 1); + rerender(); + fireEvent.click(screen.getByText('sign-out-one')); + expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_2', redirectUrl: '/after-sign-out' }); + }); + + it('drops sign-out-all and add-account in single-session mode', () => { + singleSessionMode = true; + render(); + expect(screen.getByTestId('can-sign-out-all')).toHaveTextContent('false'); + expect(screen.getByTestId('can-add-account')).toHaveTextContent('false'); + }); + + it('navigates for manage, invite, create, and add-account actions using clerk build URLs', () => { + render(); + + fireEvent.click(screen.getByText('manage-account')); + expect(navigate).toHaveBeenCalledWith('/user-profile'); + + fireEvent.click(screen.getByText('manage-org')); + expect(navigate).toHaveBeenCalledWith('/org-profile'); + + fireEvent.click(screen.getByText('invite-members')); + expect(navigate).toHaveBeenCalledWith('/org-profile'); + + fireEvent.click(screen.getByText('create-org')); + expect(navigate).toHaveBeenCalledWith('/create-org'); + + fireEvent.click(screen.getByText('add-account')); + expect(navigate).toHaveBeenCalledWith('/sign-in'); + }); + + it('accepts invitations and suggestions, then revalidates the collection', async () => { + render(); + + const invitation = userInvitations.data[0] as ReturnType; + await act(async () => { + fireEvent.click(screen.getByText('accept-invitation')); + }); + expect(invitation.accept).toHaveBeenCalledTimes(1); + expect(userInvitations.revalidate).toHaveBeenCalledTimes(1); + + const suggestion = userSuggestions.data[0] as ReturnType; + await act(async () => { + fireEvent.click(screen.getByText('accept-suggestion')); + }); + expect(suggestion.accept).toHaveBeenCalledTimes(1); + expect(userSuggestions.revalidate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/mosaic/user-button/user-button.controller.tsx b/packages/ui/src/mosaic/user-button/user-button.controller.tsx new file mode 100644 index 00000000000..8258a890336 --- /dev/null +++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx @@ -0,0 +1,185 @@ +import { useClerk, useOrganization, useSession, useUser } from '@clerk/shared/react'; +import type { OrganizationResource, UserResource } from '@clerk/shared/types'; + +import { populateParamFromObject } from '../../contexts/utils'; +import { useOrganizationListInView } from '../../hooks/useOrganizationListInView'; +import { useMosaicEnvironment } from '../hooks/useMosaicEnvironment'; +import { useMosaicRouter } from '../hooks/useMosaicRouter'; +import type { + UserButtonCallbacks, + UserButtonData, + UserButtonInvitation, + UserButtonMembership, + UserButtonSession, + UserButtonSuggestion, +} from './user-button.view'; + +// The container awaits these one-shot actions to drive busy state, so the controller exposes their +// promise; navigation callbacks stay fire-and-forget (`() => void`) and reach the view's DOM handlers. +interface UserButtonAsyncCallbacks { + onSelectOrganization?: (organizationId: string) => void | Promise; + onSwitchSession?: (sessionId: string) => void | Promise; + onSignOutSession?: (sessionId: string) => void | Promise; + onSignOutAll?: () => void | Promise; + onAcceptSuggestion?: (suggestionId: string) => void | Promise; + onAcceptInvitation?: (invitationId: string) => void | Promise; +} + +export type UserButtonController = + | { status: 'loading' } + | { status: 'hidden' } + | (UserButtonData & + Omit & + UserButtonAsyncCallbacks & { status: 'ready' }); + +// Mirrors the `` `afterSelectOrganizationUrl` prop: a full URL/path, a `:token` +// path template resolved against the organization, or a builder function. +type AfterSelectUrl = ((entity: T) => string) | string; + +export interface UserButtonControllerOptions { + afterSelectOrganizationUrl?: AfterSelectUrl; +} + +function resolveAfterSelectUrl( + config: AfterSelectUrl | undefined, + entity: OrganizationResource, +): string | undefined { + if (typeof config === 'function') { + return config(entity); + } + if (config) { + return populateParamFromObject({ urlWithParam: config, entity }); + } + return undefined; +} + +const INVITE_MEMBERS_PERMISSION = 'org:sys_memberships:manage'; + +function displayName(user: UserResource): string { + const full = [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); + if (full) { + return full; + } + if (user.username) { + return user.username; + } + return user.primaryEmailAddress?.emailAddress ?? ''; +} + +function toSession(sessionId: string, user: UserResource): UserButtonSession { + return { + sessionId, + name: displayName(user), + email: user.primaryEmailAddress?.emailAddress ?? '', + imageUrl: user.imageUrl, + }; +} + +export function useUserButtonController(options?: UserButtonControllerOptions): UserButtonController { + const { isLoaded: isUserLoaded, user } = useUser(); + const { isLoaded: isSessionLoaded, session } = useSession(); + const { isLoaded: isOrgLoaded, organization } = useOrganization(); + const { userMemberships, userInvitations, userSuggestions, ref } = useOrganizationListInView(); + + const clerk = useClerk(); + const router = useMosaicRouter(); + const environment = useMosaicEnvironment(); + const displayConfig = environment?.displayConfig; + const singleSessionMode = environment?.authConfig?.singleSessionMode ?? false; + + if (!isUserLoaded || !isSessionLoaded || !isOrgLoaded) { + return { status: 'loading' }; + } + + if (!user || !session) { + return { status: 'hidden' }; + } + + const canInviteMembers = session.checkAuthorization({ permission: INVITE_MEMBERS_PERMISSION }) ?? false; + const membershipData = userMemberships.data ?? []; + const suggestionData = userSuggestions.data ?? []; + const invitationData = userInvitations.data ?? []; + + const memberships: UserButtonMembership[] = membershipData.map(m => ({ + kind: 'membership', + organizationId: m.organization.id, + name: m.organization.name, + imageUrl: m.organization.imageUrl || undefined, + membersCount: m.organization.membersCount, + })); + + const suggestions: UserButtonSuggestion[] = suggestionData.map(s => ({ + kind: 'suggestion', + id: s.id, + organizationId: s.publicOrganizationData.id, + name: s.publicOrganizationData.name, + imageUrl: s.publicOrganizationData.imageUrl || undefined, + status: s.status, + })); + + const invitations: UserButtonInvitation[] = invitationData.map(i => ({ + kind: 'invitation', + id: i.id, + organizationId: i.publicOrganizationData.id, + organizationName: i.publicOrganizationData.name, + imageUrl: i.publicOrganizationData.imageUrl || undefined, + })); + + // Organization requests are scoped to the session that makes them, so another account's + // workspaces are unknowable until it is the active one. Sessions are all we can hand over. + const additionalSessions: UserButtonSession[] = (clerk.client?.signedInSessions ?? []).flatMap(s => { + const sessionUser = s.user; + if (!sessionUser || s.id === session.id) { + return []; + } + return [toSession(s.id, sessionUser)]; + }); + + return { + status: 'ready', + activeSession: toSession(session.id, user), + activeOrganizationId: organization?.id ?? null, + hasOrganizations: (userMemberships.count ?? 0) > 0, + memberships, + suggestions, + invitations, + additionalSessions, + paging: { + ref, + hasMore: Boolean(userMemberships.hasNextPage || userInvitations.hasNextPage || userSuggestions.hasNextPage), + }, + onSelectOrganization: organizationId => { + const selected = membershipData.find(m => m.organization.id === organizationId)?.organization; + return clerk.setActive({ + organization: organizationId, + redirectUrl: selected ? resolveAfterSelectUrl(options?.afterSelectOrganizationUrl, selected) : undefined, + }); + }, + onSwitchSession: sessionId => + clerk.setActive({ session: sessionId, redirectUrl: displayConfig?.afterSwitchSessionUrl }), + onSignOutSession: sessionId => + clerk.signOut({ + sessionId, + // Other accounts stay signed in, so route to the single-session-out URL; otherwise this is + // a full sign out. + redirectUrl: + additionalSessions.length > 0 ? clerk.buildAfterMultiSessionSingleSignOutUrl() : clerk.buildAfterSignOutUrl(), + }), + // Single-session apps cannot hold a second account, so adding one and signing out of "all + // accounts" are meaningless there; the per-account sign out on the active row remains. + onSignOutAll: singleSessionMode ? undefined : () => clerk.signOut({ redirectUrl: clerk.buildAfterSignOutUrl() }), + onManageAccount: () => void router.navigate(clerk.buildUserProfileUrl()), + onManageOrganization: () => void router.navigate(clerk.buildOrganizationProfileUrl()), + onInviteMembers: canInviteMembers ? () => void router.navigate(clerk.buildOrganizationProfileUrl()) : undefined, + onCreateOrganization: () => void router.navigate(clerk.buildCreateOrganizationUrl()), + onAddAccount: singleSessionMode ? undefined : () => void router.navigate(clerk.buildSignInUrl()), + onAcceptSuggestion: suggestionId => { + const suggestion = suggestionData.find(s => s.id === suggestionId); + return Promise.resolve(suggestion?.accept()).finally(() => void userSuggestions.revalidate?.()); + }, + onAcceptInvitation: invitationId => { + const invitation = invitationData.find(i => i.id === invitationId); + return Promise.resolve(invitation?.accept()).finally(() => void userInvitations.revalidate?.()); + }, + }; +} diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx new file mode 100644 index 00000000000..e74d0a91a5f --- /dev/null +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useState } from 'react'; + +import { useUserButtonController } from './user-button.controller'; +import { UserButtonView } from './user-button.view'; + +/** + * The connected UserButton: reads live Clerk data through `useUserButtonController` and renders + * the presentational `UserButtonView`. Owns the popover open state and closes it after a successful + * one-shot action (select/switch/sign out/accept). Actions that open another surface + * (manage/create navigations) leave the popover as-is. + */ +export function UserButton() { + const controller = useUserButtonController(); + const [open, setOpen] = useState(false); + + if (controller.status !== 'ready') { + return null; + } + + const close = () => setOpen(false); + const closeOnSuccess = (fn?: (...args: Args) => void) => + fn ? (...args: Args) => void Promise.resolve(fn(...args)).finally(close) : undefined; + + const { status: _status, ...data } = controller; + + return ( + + ); +} From a8d6d691acbe2a3177e71e91139e3db06bd667fe Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 11:19:41 -0400 Subject: [PATCH 2/7] feat(ui): add loading and busy states to UserButton --- .../ui/src/mosaic/user-button/user-button.tsx | 71 ++++++++++++++----- 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx index e74d0a91a5f..637116de434 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -2,40 +2,79 @@ import { useState } from 'react'; -import { useUserButtonController } from './user-button.controller'; -import { UserButtonView } from './user-button.view'; +import { useSpinDelay } from '../hooks/useSpinDelay'; +import { type UserButtonControllerOptions, useUserButtonController } from './user-button.controller'; +import { userButtonBusyKeys, UserButtonTriggerSkeleton, UserButtonView } from './user-button.view'; + +export type UserButtonProps = UserButtonControllerOptions; /** * The connected UserButton: reads live Clerk data through `useUserButtonController` and renders - * the presentational `UserButtonView`. Owns the popover open state and closes it after a successful - * one-shot action (select/switch/sign out/accept). Actions that open another surface - * (manage/create navigations) leave the popover as-is. + * the presentational `UserButtonView`. Owns the popover open state and the single in-flight action: + * it marks the clicked affordance busy (spinner + disables the rest), closes the popover only when + * the action resolves, and clears busy state (leaving the popover open) if it rejects. Actions that + * open another surface (manage/create navigations) leave the popover as-is. */ -export function UserButton() { - const controller = useUserButtonController(); +export function UserButton(props: UserButtonProps = {}) { + const controller = useUserButtonController(props); const [open, setOpen] = useState(false); + const [pendingKey, setPendingKey] = useState(null); + + // Hold the spinner off for quick actions and steady it once shown. Re-entry is still guarded on + // the immediate `pendingKey`; only the view's feedback is delayed. + const displayPendingKey = useSpinDelay(pendingKey); + + if (controller.status === 'loading') { + return ; + } if (controller.status !== 'ready') { return null; } const close = () => setOpen(false); - const closeOnSuccess = (fn?: (...args: Args) => void) => - fn ? (...args: Args) => void Promise.resolve(fn(...args)).finally(close) : undefined; - const { status: _status, ...data } = controller; + // Wraps a one-shot callback: block re-entry while busy, key the in-flight action for the view, + // close on success, and always clear busy so a rejection cannot leave the UI hanging. + const runAction = ( + keyFor: (...args: Args) => string, + fn?: (...args: Args) => void | Promise, + ) => + fn + ? (...args: Args) => { + if (pendingKey) { + return; + } + setPendingKey(keyFor(...args)); + void Promise.resolve(fn(...args)) + .then(close, () => {}) + .finally(() => setPendingKey(null)); + } + : undefined; + + const { + status: _status, + onSelectOrganization, + onSwitchSession, + onSignOutSession, + onSignOutAll, + onAcceptSuggestion, + onAcceptInvitation, + ...data + } = controller; return ( ); } From c22bc8fe1627aaac47f2830fd325788183c748f7 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 13:21:37 -0400 Subject: [PATCH 3/7] feat(ui): close the UserButton popover only when a workspace is picked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other action — switching account, signing out of one, joining a suggested or invited workspace — now resolves back into an open popover so the result is visible where it happened. The swingset prototypes fake the round trip they make against Clerk, so the spinner and stood-down rows are demonstrable without a running app. --- packages/ui/src/mosaic/user-button/user-button.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx index 637116de434..f502b8fd9bd 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -34,11 +34,14 @@ export function UserButton(props: UserButtonProps = {}) { const close = () => setOpen(false); - // Wraps a one-shot callback: block re-entry while busy, key the in-flight action for the view, - // close on success, and always clear busy so a rejection cannot leave the UI hanging. + // Wraps a one-shot callback: block re-entry while busy, key the in-flight action for the view, and + // always clear busy so a rejection cannot leave the UI hanging. Only an action that ends the + // interaction closes the surface; the rest resolve into a popover that re-renders around the + // result, so you can see what you just did. const runAction = ( keyFor: (...args: Args) => string, - fn?: (...args: Args) => void | Promise, + fn: ((...args: Args) => void | Promise) | undefined, + closeOnSuccess = false, ) => fn ? (...args: Args) => { @@ -47,7 +50,7 @@ export function UserButton(props: UserButtonProps = {}) { } setPendingKey(keyFor(...args)); void Promise.resolve(fn(...args)) - .then(close, () => {}) + .then(closeOnSuccess ? close : () => {}, () => {}) .finally(() => setPendingKey(null)); } : undefined; @@ -69,7 +72,7 @@ export function UserButton(props: UserButtonProps = {}) { open={open} onOpenChange={setOpen} pendingKey={displayPendingKey} - onSelectOrganization={runAction(userButtonBusyKeys.selectOrganization, onSelectOrganization)} + onSelectOrganization={runAction(userButtonBusyKeys.selectOrganization, onSelectOrganization, true)} onSwitchSession={runAction(userButtonBusyKeys.switchSession, onSwitchSession)} onSignOutSession={runAction(userButtonBusyKeys.signOutSession, onSignOutSession)} onSignOutAll={runAction(userButtonBusyKeys.signOutAll, onSignOutAll)} From 04556d67cb5a0ab5fb9ebc0938806f65180919de Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 15:08:20 -0400 Subject: [PATCH 4/7] feat(ui): name the active workspace in the UserButton trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trigger carried the avatar alone. It now names what is active beside it — the organization and its plan wherever one heads the trigger, the account otherwise — behind `showLabel`, which defaults on. Badge's `neutral` color was unreadable in both schemes: its fill is a 900 and its text token is a text color, not an on-fill one. It now rides the same black/white scrim the button's neutral fill does. --- packages/ui/src/mosaic/user-button/user-button.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx index f502b8fd9bd..7e5fd752bdf 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -4,9 +4,10 @@ import { useState } from 'react'; import { useSpinDelay } from '../hooks/useSpinDelay'; import { type UserButtonControllerOptions, useUserButtonController } from './user-button.controller'; +import type { UserButtonTriggerProps } from './user-button.view'; import { userButtonBusyKeys, UserButtonTriggerSkeleton, UserButtonView } from './user-button.view'; -export type UserButtonProps = UserButtonControllerOptions; +export type UserButtonProps = UserButtonControllerOptions & UserButtonTriggerProps; /** * The connected UserButton: reads live Clerk data through `useUserButtonController` and renders @@ -15,8 +16,8 @@ export type UserButtonProps = UserButtonControllerOptions; * the action resolves, and clears busy state (leaving the popover open) if it rejects. Actions that * open another surface (manage/create navigations) leave the popover as-is. */ -export function UserButton(props: UserButtonProps = {}) { - const controller = useUserButtonController(props); +export function UserButton({ showLabel, ...options }: UserButtonProps = {}) { + const controller = useUserButtonController(options); const [open, setOpen] = useState(false); const [pendingKey, setPendingKey] = useState(null); @@ -69,6 +70,7 @@ export function UserButton(props: UserButtonProps = {}) { return ( Date: Mon, 3 Aug 2026 15:29:20 -0400 Subject: [PATCH 5/7] refactor(ui): split the UserButton trigger label into two props `showLabel` becomes `renderTriggerLabel`, and the plan badge gets its own `renderPlanBadge`. The badge is part of the label, so it needs both. --- packages/ui/src/mosaic/user-button/user-button.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx index 7e5fd752bdf..be622af1b74 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -16,7 +16,7 @@ export type UserButtonProps = UserButtonControllerOptions & UserButtonTriggerPro * the action resolves, and clears busy state (leaving the popover open) if it rejects. Actions that * open another surface (manage/create navigations) leave the popover as-is. */ -export function UserButton({ showLabel, ...options }: UserButtonProps = {}) { +export function UserButton({ renderTriggerLabel, renderPlanBadge, ...options }: UserButtonProps = {}) { const controller = useUserButtonController(options); const [open, setOpen] = useState(false); const [pendingKey, setPendingKey] = useState(null); @@ -70,7 +70,8 @@ export function UserButton({ showLabel, ...options }: UserButtonProps = {}) { return ( Date: Mon, 3 Aug 2026 15:50:46 -0400 Subject: [PATCH 6/7] feat(ui): let combined UserButton lead with the organization or the account The trigger and the popup's header now always name the same workspace. `combined` carries both switchers, so `modePriority` picks which one it leads with: the active organization by default, the account with `modePriority="user"`. Both are still listed either way. --- packages/ui/src/mosaic/user-button/user-button.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx index be622af1b74..c7d479b4cde 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -4,10 +4,12 @@ import { useState } from 'react'; import { useSpinDelay } from '../hooks/useSpinDelay'; import { type UserButtonControllerOptions, useUserButtonController } from './user-button.controller'; -import type { UserButtonTriggerProps } from './user-button.view'; +import type { UserButtonRootProps, UserButtonTriggerProps } from './user-button.view'; import { userButtonBusyKeys, UserButtonTriggerSkeleton, UserButtonView } from './user-button.view'; -export type UserButtonProps = UserButtonControllerOptions & UserButtonTriggerProps; +export type UserButtonProps = UserButtonControllerOptions & + UserButtonTriggerProps & + Pick; /** * The connected UserButton: reads live Clerk data through `useUserButtonController` and renders @@ -16,7 +18,7 @@ export type UserButtonProps = UserButtonControllerOptions & UserButtonTriggerPro * the action resolves, and clears busy state (leaving the popover open) if it rejects. Actions that * open another surface (manage/create navigations) leave the popover as-is. */ -export function UserButton({ renderTriggerLabel, renderPlanBadge, ...options }: UserButtonProps = {}) { +export function UserButton({ renderTriggerLabel, renderPlanBadge, modePriority, ...options }: UserButtonProps = {}) { const controller = useUserButtonController(options); const [open, setOpen] = useState(false); const [pendingKey, setPendingKey] = useState(null); @@ -72,6 +74,7 @@ export function UserButton({ renderTriggerLabel, renderPlanBadge, ...options }: {...data} renderTriggerLabel={renderTriggerLabel} renderPlanBadge={renderPlanBadge} + modePriority={modePriority} open={open} onOpenChange={setOpen} pendingKey={displayPendingKey} From d2dd12d69c116263f6c7d615cc9ff10e0fed4ade Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 20:28:52 -0400 Subject: [PATCH 7/7] feat(ui): hand the UserButton its active organization and list loading state `useOrganization()` resolves before the organization list does, so the controller describes the active organization from the resource itself rather than leaving the view to find it in a list that has not arrived. `organizationsLoading` covers that window, and revoked or expired invitations are dropped since accepting is all an invitation row offers. Accepting an invitation joins the organization, so it now revalidates the membership list alongside the invitation one. --- .../__tests__/user-button.controller.test.tsx | 82 ++++++++++++++++--- .../user-button/user-button.controller.tsx | 53 ++++++++---- 2 files changed, 107 insertions(+), 28 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx index f49af448b4c..bdbe78e22ee 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx @@ -23,6 +23,7 @@ interface FakeList { data: unknown[]; count: number; hasNextPage: boolean; + isLoading: boolean; revalidate: ReturnType; } @@ -31,7 +32,7 @@ let isSessionLoaded: boolean; let isOrgLoaded: boolean; let user: FakeUser | null; let session: { id: string; checkAuthorization: ReturnType } | null; -let organization: { id: string } | null; +let organization: { id: string; name: string; imageUrl: string; membersCount: number } | null; let userMemberships: FakeList; let userInvitations: FakeList; let userSuggestions: FakeList; @@ -76,7 +77,12 @@ vi.mock('../../../hooks/useOrganizationListInView', () => ({ useOrganizationListInView: () => ({ userMemberships, userInvitations, userSuggestions, ref: pagingRef }), })); -function acceptable(id: string, orgId: string, orgName: string, status: 'pending' | 'accepted' = 'pending') { +function acceptable( + id: string, + orgId: string, + orgName: string, + status: 'pending' | 'accepted' | 'revoked' | 'expired' = 'pending', +) { return { id, status, @@ -89,8 +95,8 @@ function membership(orgId: string, name: string, membersCount: number) { return { organization: { id: orgId, name, imageUrl: '', membersCount } }; } -function list(data: unknown[], count: number, hasNextPage = false): FakeList { - return { data, count, hasNextPage, revalidate: vi.fn().mockResolvedValue(undefined) }; +function list(data: unknown[], count: number, hasNextPage = false, isLoading = false): FakeList { + return { data, count, hasNextPage, isLoading, revalidate: vi.fn().mockResolvedValue(undefined) }; } beforeEach(() => { @@ -106,7 +112,7 @@ beforeEach(() => { imageUrl: 'https://img/alice', }; session = { id: 'sess_1', checkAuthorization: (checkAuthorization = vi.fn().mockReturnValue(true)) }; - organization = { id: 'org_1' }; + organization = { id: 'org_1', name: 'Acme', imageUrl: 'https://img/acme', membersCount: 3 }; userMemberships = list([membership('org_1', 'Acme', 3), membership('org_9', 'Other', 1)], 2); userInvitations = list([acceptable('inv_1', 'org_3', 'Gamma')], 1); userSuggestions = list([acceptable('sug_1', 'org_2', 'Beta')], 1); @@ -146,8 +152,9 @@ function Harness(options: UserButtonControllerOptions = {}) { {c.activeSession.name} {c.activeSession.email} {c.activeSession.sessionId} - {String(c.activeOrganizationId)} + {JSON.stringify(c.activeOrganization)} {String(c.hasOrganizations)} + {String(c.organizationsLoading)} {c.additionalSessions.map(a => a.sessionId).join(',')} {String(c.paging?.hasMore)} {String(c.paging?.ref === pagingRef)} @@ -231,6 +238,14 @@ function memberships() { return JSON.parse(screen.getByTestId('memberships').textContent ?? '[]'); } +function invitations() { + return JSON.parse(screen.getByTestId('invitations').textContent ?? '[]'); +} + +function activeOrganization() { + return JSON.parse(screen.getByTestId('active-org').textContent ?? 'null'); +} + describe('useUserButtonController', () => { it('is loading until the user, session, and organization are all loaded', () => { isUserLoaded = false; @@ -270,13 +285,36 @@ describe('useUserButtonController', () => { expect(screen.getByTestId('active-name')).toHaveTextContent('alice@example.com'); }); - it('reflects the active organization id, and null in personal mode', () => { + it('describes the active organization whole, and null in personal mode', () => { const { rerender } = render(); - expect(screen.getByTestId('active-org')).toHaveTextContent('org_1'); + expect(activeOrganization()).toMatchObject({ + kind: 'membership', + organizationId: 'org_1', + name: 'Acme', + imageUrl: 'https://img/acme', + membersCount: 3, + }); organization = null; rerender(); - expect(screen.getByTestId('active-org')).toHaveTextContent('null'); + expect(activeOrganization()).toBeNull(); + }); + + // The trigger names it, so waiting on the list it belongs to would show the wrong workspace first. + it('names the active organization from the organization itself, not the membership list', () => { + userMemberships = list([], 0, false, true); + render(); + + expect(activeOrganization()).toMatchObject({ organizationId: 'org_1', name: 'Acme' }); + }); + + it('reports the organization list as loading until every one of its three parts has landed', () => { + const { rerender } = render(); + expect(screen.getByTestId('orgs-loading')).toHaveTextContent('false'); + + userSuggestions = list([], 0, false, true); + rerender(); + expect(screen.getByTestId('orgs-loading')).toHaveTextContent('true'); }); it('derives hasOrganizations from the membership count, not the array length', () => { @@ -310,15 +348,31 @@ describe('useUserButtonController', () => { status: 'pending', }); - const invitations = JSON.parse(screen.getByTestId('invitations').textContent ?? '[]'); - expect(invitations[0]).toMatchObject({ + expect(invitations()[0]).toMatchObject({ kind: 'invitation', id: 'inv_1', organizationId: 'org_3', organizationName: 'Gamma', + status: 'pending', }); }); + // Accepting is all an invitation row offers, and an accepted one lists as the workspace it joined. + it('lists invitations still open to the account, dropping the revoked and expired ones', () => { + userInvitations = list( + [ + acceptable('inv_1', 'org_3', 'Gamma'), + acceptable('inv_2', 'org_4', 'Delta', 'accepted'), + acceptable('inv_3', 'org_5', 'Epsilon', 'revoked'), + acceptable('inv_4', 'org_6', 'Zeta', 'expired'), + ], + 4, + ); + render(); + + expect(invitations().map((i: { id: string }) => i.id)).toEqual(['inv_1', 'inv_2']); + }); + it('reports more to page in when any of the three lists has a next page', () => { const { rerender } = render(); expect(screen.getByTestId('has-more')).toHaveTextContent('false'); @@ -399,21 +453,25 @@ describe('useUserButtonController', () => { expect(navigate).toHaveBeenCalledWith('/sign-in'); }); - it('accepts invitations and suggestions, then revalidates the collection', async () => { + it('accepts invitations and suggestions, then revalidates whatever the accept changed', async () => { render(); + // Accepting an invitation joins the organization, so the membership list is stale too. const invitation = userInvitations.data[0] as ReturnType; await act(async () => { fireEvent.click(screen.getByText('accept-invitation')); }); expect(invitation.accept).toHaveBeenCalledTimes(1); expect(userInvitations.revalidate).toHaveBeenCalledTimes(1); + expect(userMemberships.revalidate).toHaveBeenCalledTimes(1); + // A suggestion only files a request an admin has yet to approve, so nothing has been joined. const suggestion = userSuggestions.data[0] as ReturnType; await act(async () => { fireEvent.click(screen.getByText('accept-suggestion')); }); expect(suggestion.accept).toHaveBeenCalledTimes(1); expect(userSuggestions.revalidate).toHaveBeenCalledTimes(1); + expect(userMemberships.revalidate).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/ui/src/mosaic/user-button/user-button.controller.tsx b/packages/ui/src/mosaic/user-button/user-button.controller.tsx index 8258a890336..70e50961c7c 100644 --- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx @@ -66,6 +66,16 @@ function displayName(user: UserResource): string { return user.primaryEmailAddress?.emailAddress ?? ''; } +function toMembership(organization: OrganizationResource): UserButtonMembership { + return { + kind: 'membership', + organizationId: organization.id, + name: organization.name, + imageUrl: organization.imageUrl || undefined, + membersCount: organization.membersCount, + }; +} + function toSession(sessionId: string, user: UserResource): UserButtonSession { return { sessionId, @@ -100,13 +110,7 @@ export function useUserButtonController(options?: UserButtonControllerOptions): const suggestionData = userSuggestions.data ?? []; const invitationData = userInvitations.data ?? []; - const memberships: UserButtonMembership[] = membershipData.map(m => ({ - kind: 'membership', - organizationId: m.organization.id, - name: m.organization.name, - imageUrl: m.organization.imageUrl || undefined, - membersCount: m.organization.membersCount, - })); + const memberships: UserButtonMembership[] = membershipData.map(m => toMembership(m.organization)); const suggestions: UserButtonSuggestion[] = suggestionData.map(s => ({ kind: 'suggestion', @@ -117,13 +121,21 @@ export function useUserButtonController(options?: UserButtonControllerOptions): status: s.status, })); - const invitations: UserButtonInvitation[] = invitationData.map(i => ({ - kind: 'invitation', - id: i.id, - organizationId: i.publicOrganizationData.id, - organizationName: i.publicOrganizationData.name, - imageUrl: i.publicOrganizationData.imageUrl || undefined, - })); + // Accepting is all an invitation row offers, so a revoked or expired one has nothing to offer. + const invitations: UserButtonInvitation[] = invitationData.flatMap(i => + i.status === 'pending' || i.status === 'accepted' + ? [ + { + kind: 'invitation', + id: i.id, + status: i.status, + organizationId: i.publicOrganizationData.id, + organizationName: i.publicOrganizationData.name, + imageUrl: i.publicOrganizationData.imageUrl || undefined, + }, + ] + : [], + ); // Organization requests are scoped to the session that makes them, so another account's // workspaces are unknowable until it is the active one. Sessions are all we can hand over. @@ -138,8 +150,12 @@ export function useUserButtonController(options?: UserButtonControllerOptions): return { status: 'ready', activeSession: toSession(session.id, user), - activeOrganizationId: organization?.id ?? null, + activeOrganization: organization ? toMembership(organization) : null, hasOrganizations: (userMemberships.count ?? 0) > 0, + // `isLoading` is "a request is out and nothing has come back", which is the only window where + // an empty list is indistinguishable from one that has not arrived. Paging in later pages + // leaves it false, since by then the list is already on screen. + organizationsLoading: userMemberships.isLoading || userInvitations.isLoading || userSuggestions.isLoading, memberships, suggestions, invitations, @@ -177,9 +193,14 @@ export function useUserButtonController(options?: UserButtonControllerOptions): const suggestion = suggestionData.find(s => s.id === suggestionId); return Promise.resolve(suggestion?.accept()).finally(() => void userSuggestions.revalidate?.()); }, + // Accepting an invitation joins the organization, so the membership list is stale too. A + // suggestion only files a request an admin has yet to approve, so nothing has been joined. onAcceptInvitation: invitationId => { const invitation = invitationData.find(i => i.id === invitationId); - return Promise.resolve(invitation?.accept()).finally(() => void userInvitations.revalidate?.()); + return Promise.resolve(invitation?.accept()).finally(() => { + void userInvitations.revalidate?.(); + void userMemberships.revalidate?.(); + }); }, }; }