From 2e8e95a4d33a3085017ef0cf57aa5a49caf7c629 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 16 Jul 2026 17:42:45 -0400 Subject: [PATCH 01/71] 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 6b3bd6f4b8a6225df0234336beee1495e883d80d Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 11:19:41 -0400 Subject: [PATCH 02/71] 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 9ac7574c54d11b18f50437e958348154032beeb2 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 13:21:37 -0400 Subject: [PATCH 03/71] 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 668dbf54d503ee0c93433180eb102b463ff5365a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 15:08:20 -0400 Subject: [PATCH 04/71] 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 05/71] 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 06/71] 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 92744499dc5520a927b8c3b24c3675dd4837b3a8 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 20:28:52 -0400 Subject: [PATCH 07/71] 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?.(); + }); }, }; } From 7b94f02ca23dccc44ff043233992cfb2b3b797b5 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 20:53:59 -0400 Subject: [PATCH 08/71] fix(ui): answer hasOrganizations from the user resource, before the lists load The membership count is 0 until the first page lands, so the surface had no way to tell an account with no organizations from one whose list had yet to arrive, and opened a workspace section under both. The user resource carries its own memberships, so the question is settled before any request goes out; the fetched count still counts, in case the resource is behind the server. --- .../__tests__/user-button.controller.test.tsx | 14 ++++++++++++++ .../mosaic/user-button/user-button.controller.tsx | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) 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 bdbe78e22ee..b07c5cb5e81 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 @@ -12,6 +12,7 @@ interface FakeUser { username: string | null; primaryEmailAddress: { emailAddress: string } | null; imageUrl: string; + organizationMemberships: unknown[]; } interface FakeSession { @@ -110,6 +111,7 @@ beforeEach(() => { username: 'alice', primaryEmailAddress: { emailAddress: 'alice@example.com' }, imageUrl: 'https://img/alice', + organizationMemberships: [], }; session = { id: 'sess_1', checkAuthorization: (checkAuthorization = vi.fn().mockReturnValue(true)) }; organization = { id: 'org_1', name: 'Acme', imageUrl: 'https://img/acme', membersCount: 3 }; @@ -129,6 +131,7 @@ beforeEach(() => { username: null, primaryEmailAddress: { emailAddress: 'bob@example.com' }, imageUrl: 'https://img/bob', + organizationMemberships: [], }, }, ]; @@ -327,6 +330,17 @@ describe('useUserButtonController', () => { expect(screen.getByTestId('has-orgs')).toHaveTextContent('true'); }); + // The surface decides whether to carry a workspace section at all from this, so waiting on the + // list would open a section under every personal-only account and then take it away again. + it('answers hasOrganizations from the user resource before any list has loaded', () => { + userMemberships = list([], 0, false, true); + user = { ...(user as FakeUser), organizationMemberships: [{ id: 'orgmem_1' }] }; + render(); + + expect(screen.getByTestId('orgs-loading')).toHaveTextContent('true'); + 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'); 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 70e50961c7c..e2ceaa9810b 100644 --- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx @@ -151,7 +151,10 @@ export function useUserButtonController(options?: UserButtonControllerOptions): status: 'ready', activeSession: toSession(session.id, user), activeOrganization: organization ? toMembership(organization) : null, - hasOrganizations: (userMemberships.count ?? 0) > 0, + // The user resource carries its own memberships, so whether the account has any is settled + // before the paginated list is asked. The fetched count still counts, in case the resource is + // behind the server. + hasOrganizations: user.organizationMemberships.length > 0 || (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. From bf99bd959b450f574321ee6bfb8b9db6edc9fce3 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 4 Aug 2026 13:09:29 -0400 Subject: [PATCH 09/71] feat(ui): open the UserButton profiles as modals, routable by URL Managing an account and managing an organization both navigated to Clerk's built-in profile URLs. Both now open the corresponding modal instead, which is what `` and `` each already do, so returning from one puts you back where you were rather than on another page. Apps that would rather route take `userProfileUrl` and `organizationProfileUrl`, in the same url-plus-mode shape as the existing components: a URL is the whole opt-in to navigation, and `modal` forbids one, so the pair cannot contradict itself. The two profiles resolve apart, so routing one leaves the other a modal. Inviting members follows wherever managing the organization goes. It is the other way into administering the same organization, so splitting them would send one to the app's own page and the other to Clerk's. --- .../__tests__/user-button.controller.test.tsx | 67 +++++++++++++++++-- .../user-button/user-button.controller.tsx | 66 ++++++++++++++++-- 2 files changed, 123 insertions(+), 10 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 b07c5cb5e81..e80ab8f4759 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 @@ -44,6 +44,8 @@ let singleSessionMode: boolean; let setActive: ReturnType; let signOut: ReturnType; let navigate: ReturnType; +let openUserProfile: ReturnType; +let openOrganizationProfile: ReturnType; let checkAuthorization: ReturnType; vi.mock('@clerk/shared/react', async importOriginal => { @@ -57,6 +59,8 @@ vi.mock('@clerk/shared/react', async importOriginal => { navigate, setActive, signOut, + openUserProfile, + openOrganizationProfile, buildUserProfileUrl: () => '/user-profile', buildOrganizationProfileUrl: () => '/org-profile', buildCreateOrganizationUrl: () => '/create-org', @@ -138,6 +142,8 @@ beforeEach(() => { setActive = vi.fn().mockResolvedValue(undefined); signOut = vi.fn().mockResolvedValue(undefined); navigate = vi.fn().mockResolvedValue(undefined); + openUserProfile = vi.fn(); + openOrganizationProfile = vi.fn(); }); afterEach(() => { @@ -448,17 +454,70 @@ describe('useUserButtonController', () => { expect(screen.getByTestId('can-add-account')).toHaveTextContent('false'); }); - it('navigates for manage, invite, create, and add-account actions using clerk build URLs', () => { + // Both profiles open as a modal unless a URL routes instead, which is what the pre-Mosaic + // UserButton and OrganizationSwitcher each do. Nothing navigates, so the page underneath stays. + it('opens the profile modals for manage-account and manage-org', () => { render(); fireEvent.click(screen.getByText('manage-account')); - expect(navigate).toHaveBeenCalledWith('/user-profile'); + expect(openUserProfile).toHaveBeenCalled(); fireEvent.click(screen.getByText('manage-org')); - expect(navigate).toHaveBeenCalledWith('/org-profile'); + expect(openOrganizationProfile).toHaveBeenCalled(); + + expect(navigate).not.toHaveBeenCalled(); + }); + + // 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', () => { + render(); + + fireEvent.click(screen.getByText('manage-account')); + expect(navigate).toHaveBeenCalledWith('/account'); + expect(openUserProfile).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText('manage-org')); + expect(openOrganizationProfile).toHaveBeenCalled(); + }); + + it('navigates to an organization profile URL when one is given', () => { + render(); + + fireEvent.click(screen.getByText('manage-org')); + + expect(navigate).toHaveBeenCalledWith('/settings'); + expect(openOrganizationProfile).not.toHaveBeenCalled(); + }); + + // An explicit `navigation` is redundant next to a URL, but it is what the pre-Mosaic props accept, + // so passing both has to resolve the same as passing the URL alone. + it('accepts an explicit navigation mode alongside a URL', () => { + render( + , + ); + + fireEvent.click(screen.getByText('manage-org')); + + expect(navigate).toHaveBeenCalledWith('/settings'); + expect(openOrganizationProfile).not.toHaveBeenCalled(); + }); + + // Invite is the other way into administering the org, so it lands wherever manage-org lands. + // Splitting them would send one to the app's own page and the other to Clerk's. + it('sends invite-members to the same place as manage-org', () => { + render(); fireEvent.click(screen.getByText('invite-members')); - expect(navigate).toHaveBeenCalledWith('/org-profile'); + + expect(navigate).toHaveBeenCalledWith('/settings'); + }); + + it('navigates for create and add-account actions using clerk build URLs', () => { + render(); fireEvent.click(screen.getByText('create-org')); expect(navigate).toHaveBeenCalledWith('/create-org'); 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 e2ceaa9810b..989123c29d3 100644 --- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx @@ -36,9 +36,23 @@ export type UserButtonController = // path template resolved against the organization, or a builder function. type AfterSelectUrl = ((entity: T) => string) | string; -export interface UserButtonControllerOptions { - afterSelectOrganizationUrl?: AfterSelectUrl; -} +/** + * How a profile surface opens, in the shape `` and `` already + * use: a URL is the whole opt-in to navigation, and `modal` forbids one, so the pair can never + * contradict itself. The two profiles are configured apart, so routing one leaves the other a modal. + */ +type UserProfileMode = + | { userProfileUrl: string; userProfileMode?: 'navigation' } + | { userProfileUrl?: never; userProfileMode?: 'modal' }; + +type OrganizationProfileMode = + | { organizationProfileUrl: string; organizationProfileMode?: 'navigation' } + | { organizationProfileUrl?: never; organizationProfileMode?: 'modal' }; + +export type UserButtonControllerOptions = UserProfileMode & + OrganizationProfileMode & { + afterSelectOrganizationUrl?: AfterSelectUrl; + }; function resolveAfterSelectUrl( config: AfterSelectUrl | undefined, @@ -53,6 +67,28 @@ function resolveAfterSelectUrl( return undefined; } +/** + * One rule for both profiles: open the modal unless a URL routes instead. An explicit mode has the + * last word; a URL on its own means navigation, so passing one is all it takes to route. `url` + * falls back to Clerk's own so an explicit `navigation` still lands somewhere. + */ +function profileAction({ + url, + mode, + openModal, + buildUrl, + navigate, +}: { + url: string | undefined; + mode: 'navigation' | 'modal' | undefined; + openModal: () => void; + buildUrl: () => string; + navigate: (to: string) => unknown; +}): () => void { + const resolved = mode ?? (url ? 'navigation' : 'modal'); + return resolved === 'navigation' ? () => void navigate(url ?? buildUrl()) : () => openModal(); +} + const INVITE_MEMBERS_PERMISSION = 'org:sys_memberships:manage'; function displayName(user: UserResource): string { @@ -97,6 +133,22 @@ export function useUserButtonController(options?: UserButtonControllerOptions): const displayConfig = environment?.displayConfig; const singleSessionMode = environment?.authConfig?.singleSessionMode ?? false; + const manageAccount = profileAction({ + url: options?.userProfileUrl, + mode: options?.userProfileMode, + openModal: () => clerk.openUserProfile(), + buildUrl: () => clerk.buildUserProfileUrl(), + navigate: router.navigate, + }); + + const manageOrganization = profileAction({ + url: options?.organizationProfileUrl, + mode: options?.organizationProfileMode, + openModal: () => clerk.openOrganizationProfile(), + buildUrl: () => clerk.buildOrganizationProfileUrl(), + navigate: router.navigate, + }); + if (!isUserLoaded || !isSessionLoaded || !isOrgLoaded) { return { status: 'loading' }; } @@ -187,9 +239,11 @@ export function useUserButtonController(options?: UserButtonControllerOptions): // 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, + onManageAccount: manageAccount, + onManageOrganization: manageOrganization, + // Invite is the other way into administering the organization, so it lands wherever managing it + // lands. Splitting them would send one to the app's own page and the other to Clerk's. + onInviteMembers: canInviteMembers ? manageOrganization : undefined, onCreateOrganization: () => void router.navigate(clerk.buildCreateOrganizationUrl()), onAddAccount: singleSessionMode ? undefined : () => void router.navigate(clerk.buildSignInUrl()), onAcceptSuggestion: suggestionId => { From a4bbae04fa4b61598be6a2e6625f6b232a6cc7f0 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 5 Aug 2026 10:02:32 -0400 Subject: [PATCH 10/71] feat(ui): portal the UserButton profile modals The profile modals opened into `document.body`, so an app that mounts the button inside its own dialog or popover got the modal rendered behind it. Both now open into the portal root from `usePortalRoot`, matching what the pre-Mosaic `` and `` pass as `getContainer`. --- .../__tests__/user-button.controller.test.tsx | 17 +++++++++++++++++ .../user-button/user-button.controller.tsx | 9 ++++++--- 2 files changed, 23 insertions(+), 3 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 e80ab8f4759..659d89c6bd7 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 @@ -47,6 +47,7 @@ let navigate: ReturnType; let openUserProfile: ReturnType; let openOrganizationProfile: ReturnType; let checkAuthorization: ReturnType; +let getContainer: () => HTMLElement | null; vi.mock('@clerk/shared/react', async importOriginal => { const actual = await importOriginal(); @@ -55,6 +56,9 @@ vi.mock('@clerk/shared/react', async importOriginal => { useUser: () => ({ isLoaded: isUserLoaded, user }), useSession: () => ({ isLoaded: isSessionLoaded, session }), useOrganization: () => ({ isLoaded: isOrgLoaded, organization }), + // Stubbed with a sentinel so the assertion is that this exact function reaches Clerk, rather + // than that some function did. + usePortalRoot: () => getContainer, useClerk: () => ({ navigate, setActive, @@ -144,6 +148,7 @@ beforeEach(() => { navigate = vi.fn().mockResolvedValue(undefined); openUserProfile = vi.fn(); openOrganizationProfile = vi.fn(); + getContainer = () => null; }); afterEach(() => { @@ -468,6 +473,18 @@ describe('useUserButtonController', () => { expect(navigate).not.toHaveBeenCalled(); }); + // An app that mounts the button inside its own dialog or popover puts a portal root around it, and + // the modal has to land there too or it renders behind the surface that opened it. + it('opens the profile modals into the portal root the app configured', () => { + render(); + + fireEvent.click(screen.getByText('manage-account')); + expect(openUserProfile).toHaveBeenCalledWith({ getContainer }); + + fireEvent.click(screen.getByText('manage-org')); + expect(openOrganizationProfile).toHaveBeenCalledWith({ getContainer }); + }); + // 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', () => { 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 989123c29d3..b4567f6dfbd 100644 --- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx @@ -1,4 +1,4 @@ -import { useClerk, useOrganization, useSession, useUser } from '@clerk/shared/react'; +import { useClerk, useOrganization, usePortalRoot, useSession, useUser } from '@clerk/shared/react'; import type { OrganizationResource, UserResource } from '@clerk/shared/types'; import { populateParamFromObject } from '../../contexts/utils'; @@ -129,6 +129,9 @@ export function useUserButtonController(options?: UserButtonControllerOptions): const clerk = useClerk(); const router = useMosaicRouter(); + // An app can mount the button inside its own dialog or popover; the modal has to portal into that + // same root or it renders behind the surface that opened it. + const getContainer = usePortalRoot(); const environment = useMosaicEnvironment(); const displayConfig = environment?.displayConfig; const singleSessionMode = environment?.authConfig?.singleSessionMode ?? false; @@ -136,7 +139,7 @@ export function useUserButtonController(options?: UserButtonControllerOptions): const manageAccount = profileAction({ url: options?.userProfileUrl, mode: options?.userProfileMode, - openModal: () => clerk.openUserProfile(), + openModal: () => clerk.openUserProfile({ getContainer }), buildUrl: () => clerk.buildUserProfileUrl(), navigate: router.navigate, }); @@ -144,7 +147,7 @@ export function useUserButtonController(options?: UserButtonControllerOptions): const manageOrganization = profileAction({ url: options?.organizationProfileUrl, mode: options?.organizationProfileMode, - openModal: () => clerk.openOrganizationProfile(), + openModal: () => clerk.openOrganizationProfile({ getContainer }), buildUrl: () => clerk.buildOrganizationProfileUrl(), navigate: router.navigate, }); From 4e91d5dbc83e3bb2d8bfaa979c3f19503bdeefba Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 5 Aug 2026 10:02:32 -0400 Subject: [PATCH 11/71] feat(ui): offer the personal workspace from the UserButton controller Switching into an organization was a one-way door: nothing on the surface cleared the active one. The controller now offers the account's own workspace as a selectable row, and withholds it where there are no organizations to leave. --- .../__tests__/user-button.controller.test.tsx | 15 +++++++++++++++ .../mosaic/user-button/user-button.controller.tsx | 8 ++++++-- 2 files changed, 21 insertions(+), 2 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 659d89c6bd7..eae6372bcc4 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 @@ -184,6 +184,12 @@ function Harness(options: UserButtonControllerOptions = {}) { > select-org + - - - - - - - + {c.customMenuItems?.map(item => + item.href === undefined ? ( + + ) : ( + + {item.label} + + ), + )} ); } -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; - const { rerender } = render(); + it('passes loading and hidden through until the model is ready', () => { + 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'); - }); - - // Every instance-level answer the surface needs — organizations, single-session, forced - // selection — comes off the environment, and it hydrates on its own schedule. Reporting ready - // without it would mean guessing at all three and rearranging once it lands. - it('is loading until the environment has hydrated', () => { - environmentHydrated = false; - const { rerender } = render(); - expect(screen.getByTestId('status')).toHaveTextContent('loading'); - - environmentHydrated = true; - rerender(); - expect(screen.getByTestId('status')).toHaveTextContent('ready'); - }); - - it('reports whether the instance has organizations at all', () => { - render(); - expect(screen.getByTestId('orgs-enabled')).toHaveTextContent('true'); - - cleanup(); - organizationsEnabled = false; - render(); - expect(screen.getByTestId('orgs-enabled')).toHaveTextContent('false'); - }); - - it('is hidden when loaded but there is no active user', () => { - user = null; - render(); + rerender(); expect(screen.getByTestId('status')).toHaveTextContent('hidden'); - }); - it('maps the active account and prefers first+last > username > email for the name', () => { - const { rerender } = render(); + rerender(); expect(screen.getByTestId('status')).toHaveTextContent('ready'); - expect(screen.getByTestId('active-name')).toHaveTextContent('Alice Smith'); - 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('identifies the active account by username, then email, then phone, then wallet', () => { - const { rerender } = render(); - expect(screen.getByTestId('active-identifier')).toHaveTextContent('alice'); - - user = { ...(user as FakeUser), username: null }; - rerender(); - expect(screen.getByTestId('active-identifier')).toHaveTextContent('alice@example.com'); - - user = { ...user, primaryEmailAddress: null, primaryPhoneNumber: { phoneNumber: '+15550100' } }; - rerender(); - expect(screen.getByTestId('active-identifier')).toHaveTextContent('+15550100'); + it('forces user mode when organizations are disabled, whatever mode was asked for', () => { + const { rerender } = render( + , + ); + expect(screen.getByTestId('mode')).toHaveTextContent('user'); - user = { ...user, primaryPhoneNumber: null, primaryWeb3Wallet: { web3Wallet: '0xabc' } }; - rerender(); - expect(screen.getByTestId('active-identifier')).toHaveTextContent('0xabc'); + rerender( + , + ); + expect(screen.getByTestId('mode')).toHaveTextContent('organization'); }); - it('describes the active organization whole, and null in personal mode', () => { - const { rerender } = render(); - expect(activeOrganization()).toMatchObject({ - kind: 'membership', - organizationId: 'org_1', - name: 'Acme', - imageUrl: 'https://img/acme', - membersCount: 3, - }); + it('runs a model action through the machine and keys the affordance', async () => { + const onSelectOrganization = vi.fn(() => Promise.resolve()); + render(); - organization = null; - rerender(); - expect(activeOrganization()).toBeNull(); - }); + fireEvent.click(screen.getByText('open')); + fireEvent.click(screen.getByText('select-org')); - it('names the active organization from the organization itself, not the membership list', () => { - userMemberships = list([], 0, false, true); - render(); + expect(onSelectOrganization).toHaveBeenCalledWith('org_1'); + await waitFor(() => expect(screen.getByTestId('pending')).toHaveTextContent('select-org:org_1')); - expect(activeOrganization()).toMatchObject({ organizationId: 'org_1', name: 'Acme' }); + await act(async () => { + await tick(); + }); + expect(screen.getByTestId('open')).toHaveTextContent('false'); + expect(screen.getByTestId('pending')).toHaveTextContent(''); }); - 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'); + it('closes immediately on a hand-off and leaves the model action to run', () => { + const onManageAccount = vi.fn(); + render(); - userSuggestions = list([], 0, false, true); - rerender(); - expect(screen.getByTestId('orgs-loading')).toHaveTextContent('true'); - }); - - 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'); + fireEvent.click(screen.getByText('open')); + fireEvent.click(screen.getByText('manage-account')); - userMemberships = list([], 5); - rerender(); - expect(screen.getByTestId('has-orgs')).toHaveTextContent('true'); + expect(onManageAccount).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('open')).toHaveTextContent('false'); }); - // Waiting on the list would open a workspace section under every personal-only account, then - // take it away again. - it('answers hasOrganizations from the user resource before any list has loaded', () => { - userMemberships = list([], 0, false, true); - user = { ...(user as FakeUser), organizationMemberships: [{ id: 'orgmem_1' }] }; - render(); + it('closes the popover before a custom menu action runs', () => { + const onClick = vi.fn(); + render( + , + ); - expect(screen.getByTestId('orgs-loading')).toHaveTextContent('true'); - expect(screen.getByTestId('has-orgs')).toHaveTextContent('true'); - }); + fireEvent.click(screen.getByText('open')); + fireEvent.click(screen.getByText('Documentation')); - 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'); + expect(onClick).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('open')).toHaveTextContent('false'); }); - 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', - }); - - expect(invitations()[0]).toMatchObject({ - kind: 'invitation', - id: 'inv_1', - organizationId: 'org_3', - organizationName: 'Gamma', - status: 'pending', - }); - }); + it('starts closed and opens and closes', () => { + render(); + expect(screen.getByTestId('open')).toHaveTextContent('false'); - 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(); + fireEvent.click(screen.getByText('open')); + expect(screen.getByTestId('open')).toHaveTextContent('true'); - expect(invitations().map((i: { id: string }) => i.id)).toEqual(['inv_1', 'inv_2']); + fireEvent.click(screen.getByText('close')); + expect(screen.getByTestId('open')).toHaveTextContent('false'); }); - 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'); + it('holds the popup open when an action fails, even one that would have closed it', async () => { + const onSelectOrganization = vi.fn(() => Promise.reject(new Error('cannot switch'))); + render(); - 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' }); + fireEvent.click(screen.getByText('open')); + fireEvent.click(screen.getByText('select-org')); - checkAuthorization.mockReturnValue(false); - rerender(); - expect(screen.getByTestId('can-invite')).toHaveTextContent('false'); + await act(async () => { + await tick(); + }); + expect(screen.getByTestId('open')).toHaveTextContent('true'); + expect(screen.getByTestId('pending')).toHaveTextContent(''); }); - 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 }); + it('lets the row be clicked again after a failure', async () => { + const onSelectOrganization = vi.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce(undefined); + render(); - rerender(); + fireEvent.click(screen.getByText('open')); fireEvent.click(screen.getByText('select-org')); - expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/orgs/org_9' }); + await act(async () => { + await tick(); + }); - rerender( `/o/${org.name}`} />); fireEvent.click(screen.getByText('select-org')); - expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/o/Other' }); - }); - - // `null` is Clerk's own name for the personal workspace, and there is no organization for - // `afterSelectOrganizationUrl` to resolve against. - it('selects the personal workspace by clearing the active organization', () => { - render(); - - fireEvent.click(screen.getByText('select-personal')); - expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: undefined }); - }); - - it('redirects the personal workspace to the configured afterSelectPersonalUrl', () => { - const { rerender } = render(); - - fireEvent.click(screen.getByText('select-personal')); - expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: '/u/user_1' }); + expect(onSelectOrganization).toHaveBeenCalledTimes(2); - rerender( `/u/${u.username}`} />); - fireEvent.click(screen.getByText('select-personal')); - expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: '/u/alice' }); + await act(async () => { + await tick(); + }); + expect(screen.getByTestId('open')).toHaveTextContent('false'); }); - // The two are configured apart, so routing the personal workspace leaves the organizations alone. - it('keeps the personal redirect off the organizations', () => { - render(); + it('refuses a second action while one is in flight', async () => { + const pending = deferred(); + const onSelectOrganization = vi.fn(() => pending.promise); + const onSwitchSession = vi.fn(() => Promise.resolve()); + render(); + fireEvent.click(screen.getByText('open')); fireEvent.click(screen.getByText('select-org')); - expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined }); - }); - - // An instance that requires an organization has no personal workspace: clerk-js refuses - // `setActive({ organization: null })` outright there, so offering the switch would offer nothing. - it('reports no personal workspace where the instance forces an organization', () => { - const { rerender } = render(); - expect(screen.getByTestId('hide-personal')).toHaveTextContent('false'); - - forceOrganizationSelection = true; - rerender(); - expect(screen.getByTestId('hide-personal')).toHaveTextContent('true'); - }); - - // An app whose organizations are the whole product withholds it itself. The instance setting is - // the other way in, and neither one can be talked out of it by the other. - it('lets the app withhold the personal workspace on an instance that allows one', () => { - const { rerender } = render(); - expect(screen.getByTestId('hide-personal')).toHaveTextContent('true'); - - forceOrganizationSelection = true; - rerender(); - expect(screen.getByTestId('hide-personal')).toHaveTextContent('true'); - }); - - it('switches sessions and routes each sign out to the URL that matches what is left', () => { - const { rerender } = render(); + await waitFor(() => expect(screen.getByTestId('pending')).toHaveTextContent('select-org:org_1')); 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' }); - }); - - // The session switched to can land on a task of its own. A plain `redirectUrl` routes past it and - // strands the account, so the switch hands `setActive` a callback that answers both cases. - it('routes a switched session to its pending task, and to the after-switch URL when it has none', async () => { - render(); - fireEvent.click(screen.getByText('switch')); - - expect(setActive).toHaveBeenCalledWith({ session: 'sess_2', navigate: expect.any(Function) }); - const navigateOnSetActive = setActive.mock.calls[0][0].navigate; - const decorateUrl = vi.fn((url: string) => url); + expect(onSwitchSession).not.toHaveBeenCalled(); await act(async () => { - await navigateOnSetActive({ session: { currentTask: { key: 'choose-organization' } }, decorateUrl }); + pending.resolve(undefined); + await tick(); }); - expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/sign-in')); - expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/tasks/choose-organization')); - - await act(async () => { - await navigateOnSetActive({ session: { currentTask: null }, decorateUrl }); - }); - expect(navigate).toHaveBeenCalledWith('/after-switch'); - // `redirectUrl` was decorated for us; taking the callback takes the Safari ITP refresh with it. - expect(decorateUrl).toHaveBeenCalledWith('/after-switch'); }); - // An instance can restrict who may open an organization, and a user at their creation limit is - // restricted the same way. Offering the action anyway lands them on a page that turns them away. - it('drops create-organization for a user who cannot open one', () => { - const { rerender } = render(); - expect(screen.getByTestId('can-create-org')).toHaveTextContent('true'); + it('refuses an action while the popup is closed', () => { + const onSelectOrganization = vi.fn(() => Promise.resolve()); + render(); - user = { ...(user as FakeUser), createOrganizationEnabled: false }; - rerender(); - expect(screen.getByTestId('can-create-org')).toHaveTextContent('false'); - }); - - 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'); - }); - - // An instance that has paid the branding off carries none of it, and the environment is the only - // place that answer lives. - it('carries the branding the instance is on, not the branding everyone gets', () => { - render(); - expect(screen.getByTestId('branded')).toHaveTextContent('true'); - - cleanup(); - branded = false; - render(); - expect(screen.getByTestId('branded')).toHaveTextContent('false'); - }); - - // Both profiles open as a modal unless a URL routes instead, which is what the pre-Mosaic - // UserButton and OrganizationSwitcher each do. Nothing navigates, so the page underneath stays. - it('opens the profile modals for manage-account and manage-org', () => { - render(); - - fireEvent.click(screen.getByText('manage-account')); - expect(openUserProfile).toHaveBeenCalled(); - - fireEvent.click(screen.getByText('manage-org')); - expect(openOrganizationProfile).toHaveBeenCalled(); - - expect(navigate).not.toHaveBeenCalled(); - }); - - // An app that mounts the button inside its own dialog or popover puts a portal root around it, and - // the modal has to land there too or it renders behind the surface that opened it. - it('opens the profile modals into the portal root the app configured', () => { - render(); - - fireEvent.click(screen.getByText('manage-account')); - expect(openUserProfile).toHaveBeenCalledWith({ getContainer }); + fireEvent.click(screen.getByText('select-org')); - fireEvent.click(screen.getByText('manage-org')); - expect(openOrganizationProfile).toHaveBeenCalledWith({ getContainer }); + expect(onSelectOrganization).not.toHaveBeenCalled(); + expect(screen.getByTestId('open')).toHaveTextContent('false'); }); - // 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(); - - fireEvent.click(screen.getByText('manage-account')); + it('abandons an action dismissed mid-flight rather than reopening on its result', async () => { + const pending = deferred(); + const onSwitchSession = vi.fn(() => pending.promise); + render(); - expect(openUserProfile).toHaveBeenCalledWith({ getContainer, customPages }); - }); + fireEvent.click(screen.getByText('open')); + fireEvent.click(screen.getByText('switch')); + await waitFor(() => expect(screen.getByTestId('pending')).toHaveTextContent('switch:sess_2')); - // 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', () => { - render(); + fireEvent.click(screen.getByText('close')); + expect(screen.getByTestId('open')).toHaveTextContent('false'); - fireEvent.click(screen.getByText('manage-account')); - expect(navigate).toHaveBeenCalledWith('/account'); - expect(openUserProfile).not.toHaveBeenCalled(); - - fireEvent.click(screen.getByText('manage-org')); - expect(openOrganizationProfile).toHaveBeenCalled(); + await act(async () => { + pending.resolve(undefined); + await tick(); + }); + expect(screen.getByTestId('open')).toHaveTextContent('false'); }); - it('navigates to an organization profile URL when one is given', () => { - render(); + it('holds the surface on the model the action started from until it settles', async () => { + const pending = deferred(); + const onSwitchSession = vi.fn(() => pending.promise); + const { rerender } = render(); - fireEvent.click(screen.getByText('manage-org')); - - expect(navigate).toHaveBeenCalledWith('/settings'); - expect(openOrganizationProfile).not.toHaveBeenCalled(); - }); + fireEvent.click(screen.getByText('open')); + fireEvent.click(screen.getByText('switch')); - // An explicit `navigation` is redundant next to a URL, but it is what the pre-Mosaic props accept, - // so passing both has to resolve the same as passing the URL alone. - it('accepts an explicit navigation mode alongside a URL', () => { - render( + rerender( , ); - fireEvent.click(screen.getByText('manage-org')); - - expect(navigate).toHaveBeenCalledWith('/settings'); - expect(openOrganizationProfile).not.toHaveBeenCalled(); - }); - - // Invite opens its own modal rather than following manage-org: there is no invite page to route - // to, so an app that routes organization management to its own page still gets the form here. - it('opens the invite-members modal into the portal root, whatever manage-org is routed to', () => { - render(); - - fireEvent.click(screen.getByText('invite-members')); - - expect(openInviteMembers).toHaveBeenCalledWith({ getContainer }); - expect(navigate).not.toHaveBeenCalled(); - }); - - // Creating an organization resolves like the two profiles do: a modal unless a URL routes - // instead. Adding an account always leaves, since signing in cannot happen inside the popover. - it('opens the create-organization modal into the portal root, and navigates for add-account', () => { - render(); - - fireEvent.click(screen.getByText('create-org')); - expect(openCreateOrganization).toHaveBeenCalledWith({ getContainer }); - expect(navigate).not.toHaveBeenCalled(); - - fireEvent.click(screen.getByText('add-account')); - expect(navigate).toHaveBeenCalledWith('/sign-in'); - }); + expect(screen.getByTestId('active-org')).toHaveTextContent(''); + expect(screen.getByTestId('open')).toHaveTextContent('true'); - it('navigates to a create-organization URL when one is given', () => { - render(); - - fireEvent.click(screen.getByText('create-org')); - - expect(navigate).toHaveBeenCalledWith('/new-org'); - expect(openCreateOrganization).not.toHaveBeenCalled(); - }); - - // Without a URL there is nothing to navigate to but Clerk's own page, which is what an explicit - // `navigation` asks for. - it('falls back to the clerk create-organization URL for an explicit navigation mode', () => { - render(); - - fireEvent.click(screen.getByText('create-org')); - - expect(navigate).toHaveBeenCalledWith('/create-org'); - expect(openCreateOrganization).not.toHaveBeenCalled(); - }); - - 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')); + pending.resolve(undefined); + await tick(); }); - 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); + expect(screen.getByTestId('active-org')).toHaveTextContent('org_1'); + expect(screen.getByTestId('open')).toHaveTextContent('true'); }); }); diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx index ba8c5fdffe9..0e38e876e03 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx @@ -9,8 +9,8 @@ import type { UserButtonProps } from '../user-button'; import { UserButton } from '../user-button'; // End-to-end wiring test for the connected UserButton: it renders the real view through the real -// controller against a mocked Clerk, then drives the real popover DOM. Unlike the controller test -// (controller -> Clerk), this proves the layers compose, including what closes the popover: +// model and controller against a mocked Clerk, then drives the real popover DOM. Unlike the model +// test (model -> Clerk), this proves the layers compose, including what closes the popover: // selecting a workspace closes on success in the machine, and anything that opens a modal or // navigates closes before it hands off. @@ -308,7 +308,7 @@ describe('UserButton (connected)', () => { await act.click(screen.getByRole('button', { name: 'bob@example.com' })); - expect(setActive).toHaveBeenCalledWith({ session: 'sess_2', redirectUrl: '/after-switch' }); + expect(setActive).toHaveBeenCalledWith({ session: 'sess_2', navigate: expect.any(Function) }); await waitFor(() => expect(spinner()).toBeNull()); expect(popup()).toBeInTheDocument(); }); diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.machine.test.ts b/packages/ui/src/mosaic/user-button/__tests__/user-button.machine.test.ts deleted file mode 100644 index 3d9201a1e22..00000000000 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.machine.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { createActor } from '../../machine/createActor'; -import type { UserButtonReadyController } from '../user-button.machine'; -import { userButtonMachine } from '../user-button.machine'; - -const tick = () => new Promise(resolve => setTimeout(resolve, 0)); - -const ready: UserButtonReadyController = { - status: 'ready', - activeSession: { sessionId: 'sess_1', name: 'Alice', identifier: 'alice@example.com' }, - activeOrganization: null, - hasOrganizations: false, - memberships: [], - suggestions: [], - invitations: [], - additionalSessions: [], -}; - -const run = ( - overrides: Partial<{ key: string; run: () => Promise; closeOnSuccess: boolean }> = {}, -): { - type: 'RUN'; - key: string; - frozen: UserButtonReadyController; - run: () => Promise; - closeOnSuccess: boolean; -} => ({ - type: 'RUN', - key: 'selectOrganization:org_1', - frozen: ready, - run: () => Promise.resolve(), - closeOnSuccess: false, - ...overrides, -}); - -const opened = () => { - const actor = createActor(userButtonMachine); - actor.start(); - actor.send({ type: 'OPEN' }); - return actor; -}; - -describe('userButtonMachine', () => { - it('starts closed', () => { - const actor = createActor(userButtonMachine); - actor.start(); - - expect(actor.getSnapshot().value).toBe('closed'); - }); - - it('opens and closes', () => { - const actor = opened(); - expect(actor.getSnapshot().value).toBe('open'); - - actor.send({ type: 'CLOSE' }); - expect(actor.getSnapshot().value).toBe('closed'); - }); - - it('keys the affordance, freezes the controller, and runs the injected effect', () => { - const effect = vi.fn(() => Promise.resolve()); - const actor = opened(); - - actor.send(run({ run: effect })); - - expect(actor.getSnapshot().value).toBe('busy'); - expect(actor.getSnapshot().context.pendingKey).toBe('selectOrganization:org_1'); - expect(actor.getSnapshot().context.frozen).toBe(ready); - expect(effect).toHaveBeenCalledTimes(1); - }); - - it('settles back into the open popup, releasing the freeze', async () => { - const actor = opened(); - - actor.send(run()); - await tick(); - - expect(actor.getSnapshot().value).toBe('open'); - expect(actor.getSnapshot().context.pendingKey).toBeNull(); - expect(actor.getSnapshot().context.frozen).toBeNull(); - }); - - it('closes on success for an action that ends the interaction', async () => { - const actor = opened(); - - actor.send(run({ closeOnSuccess: true })); - await tick(); - - expect(actor.getSnapshot().value).toBe('closed'); - expect(actor.getSnapshot().context.pendingKey).toBeNull(); - }); - - it('holds the popup open when an action fails, even one that would have closed it', async () => { - const actor = opened(); - - actor.send(run({ closeOnSuccess: true, run: () => Promise.reject(new Error('cannot switch')) })); - await tick(); - - expect(actor.getSnapshot().value).toBe('open'); - expect(actor.getSnapshot().context.pendingKey).toBeNull(); - expect(actor.getSnapshot().context.frozen).toBeNull(); - }); - - it('lets the row be clicked again after a failure', async () => { - const actor = opened(); - - actor.send(run({ run: () => Promise.reject(new Error('boom')) })); - await tick(); - - const retry = vi.fn(() => Promise.resolve()); - actor.send(run({ run: retry })); - - expect(actor.getSnapshot().value).toBe('busy'); - expect(retry).toHaveBeenCalledTimes(1); - }); - - it('refuses a second action while one is in flight', () => { - const second = vi.fn(() => Promise.resolve()); - const actor = opened(); - - actor.send(run({ key: 'signOutAll' })); - actor.send(run({ key: 'switchSession:sess_2', run: second })); - - expect(actor.getSnapshot().context.pendingKey).toBe('signOutAll'); - expect(second).not.toHaveBeenCalled(); - }); - - it('refuses an action while the popup is closed', () => { - const effect = vi.fn(() => Promise.resolve()); - const actor = createActor(userButtonMachine); - actor.start(); - - actor.send(run({ run: effect })); - - expect(actor.getSnapshot().value).toBe('closed'); - expect(effect).not.toHaveBeenCalled(); - }); - - it('abandons an action dismissed mid-flight rather than reopening on its result', async () => { - const actor = opened(); - - actor.send(run()); - actor.send({ type: 'CLOSE' }); - - expect(actor.getSnapshot().value).toBe('closed'); - expect(actor.getSnapshot().context.pendingKey).toBeNull(); - - await tick(); - - expect(actor.getSnapshot().value).toBe('closed'); - }); -}); diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx new file mode 100644 index 00000000000..c541932e966 --- /dev/null +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx @@ -0,0 +1,805 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { useOrganization } 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'; + +import { useOrganizationListInView } from '../../../hooks/useOrganizationListInView'; +import type { UserButtonModelOptions } from '../user-button.model'; +import { useUserButtonModel } from '../user-button.model'; + +interface FakeUser { + id: string; + firstName: string | null; + lastName: string | null; + username: string | null; + primaryEmailAddress: { emailAddress: string } | null; + primaryPhoneNumber?: { phoneNumber: string } | null; + primaryWeb3Wallet?: { web3Wallet: string } | null; + imageUrl: string; + organizationMemberships: unknown[]; + createOrganizationEnabled: boolean; +} + +interface FakeSession { + id: string; + user: FakeUser; +} + +interface FakeList { + data: unknown[]; + count: number; + hasNextPage: boolean; + isLoading: 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; name: string; imageUrl: string; membersCount: number } | null; +let userMemberships: FakeList; +let userInvitations: FakeList; +let userSuggestions: FakeList; +let signedInSessions: FakeSession[]; +let pagingRef: (element: HTMLElement | null) => void; +let singleSessionMode: boolean; +let branded: boolean; +let forceOrganizationSelection: boolean; +let organizationsEnabled: boolean; +// False stands for the window before clerk-js has hydrated it, which the model has to sit out. +let environmentHydrated: boolean; + +// Built per read rather than once, so a test setting any of the flags above is answered by it. +function environment() { + return environmentHydrated + ? { + displayConfig: { afterSwitchSessionUrl: '/after-switch', branded }, + authConfig: { singleSessionMode }, + organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection }, + } + : null; +} + +let setActive: ReturnType; +let signOut: ReturnType; +let navigate: ReturnType; +let openUserProfile: ReturnType; +let openOrganizationProfile: ReturnType; +let openCreateOrganization: ReturnType; +let openInviteMembers: ReturnType; +let checkAuthorization: ReturnType; +let getContainer: () => HTMLElement | null; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ isLoaded: isUserLoaded, user }), + useSession: () => ({ isLoaded: isSessionLoaded, session }), + useOrganization: vi.fn(() => ({ isLoaded: isOrgLoaded, organization })), + // Stubbed with a sentinel so the assertion is that this exact function reaches Clerk, rather + // than that some function did. + usePortalRoot: () => getContainer, + useClerk: () => ({ + navigate, + setActive, + signOut, + openUserProfile, + openOrganizationProfile, + openCreateOrganization, + openInviteMembers, + 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: environment(), + }), + }; +}); + +// The model 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: vi.fn(() => ({ userMemberships, userInvitations, userSuggestions, ref: pagingRef })), +})); + +function acceptable( + id: string, + orgId: string, + orgName: string, + status: 'pending' | 'accepted' | 'revoked' | 'expired' = '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, isLoading = false): FakeList { + return { data, count, hasNextPage, isLoading, 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', + organizationMemberships: [], + createOrganizationEnabled: true, + }; + session = { id: 'sess_1', checkAuthorization: (checkAuthorization = vi.fn().mockReturnValue(true)) }; + 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); + pagingRef = vi.fn(); + singleSessionMode = false; + branded = true; + forceOrganizationSelection = false; + organizationsEnabled = true; + environmentHydrated = true; + 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', + organizationMemberships: [], + createOrganizationEnabled: true, + }, + }, + ]; + setActive = vi.fn().mockResolvedValue(undefined); + signOut = vi.fn().mockResolvedValue(undefined); + navigate = vi.fn().mockResolvedValue(undefined); + openUserProfile = vi.fn(); + openOrganizationProfile = vi.fn(); + openCreateOrganization = vi.fn(); + openInviteMembers = vi.fn(); + getContainer = () => null; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function Harness({ customPages, ...options }: UserButtonModelOptions & { customPages?: CustomPage[] } = {}) { + const c = useUserButtonModel(options, customPages); + if (c.status !== 'ready') { + return {c.status}; + } + return ( +
+ {c.status} + {c.activeSession.name} + {c.activeSession.identifier} + {c.activeSession.sessionId} + {JSON.stringify(c.activeOrganization)} + {String(c.hasOrganizations)} + {String(c.organizationsEnabled)} + {String(c.renderBranding)} + {String(c.hidePersonal)} + {String(c.organizationsLoading)} + {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))} + {String(Boolean(c.onCreateOrganization))} + {JSON.stringify(c.memberships)} + {JSON.stringify(c.suggestions)} + {JSON.stringify(c.invitations)} + + + + + + + + + + + + +
+ ); +} + +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('useUserButtonModel', () => { + 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'); + }); + + // Every instance-level answer the surface needs — organizations, single-session, forced + // selection — comes off the environment, and it hydrates on its own schedule. Reporting ready + // without it would mean guessing at all three and rearranging once it lands. + it('is loading until the environment has hydrated', () => { + environmentHydrated = false; + const { rerender } = render(); + expect(screen.getByTestId('status')).toHaveTextContent('loading'); + + environmentHydrated = true; + rerender(); + expect(screen.getByTestId('status')).toHaveTextContent('ready'); + }); + + it('reports whether the instance has organizations at all', () => { + render(); + expect(screen.getByTestId('orgs-enabled')).toHaveTextContent('true'); + expect(useOrganizationListInView).toHaveBeenCalledWith({ enabled: true }); + + cleanup(); + organizationsEnabled = false; + render(); + expect(screen.getByTestId('orgs-enabled')).toHaveTextContent('false'); + expect(useOrganizationListInView).toHaveBeenCalledWith({ enabled: false }); + }); + + it('does not fetch the organization lists until the environment says they are on', () => { + environmentHydrated = false; + const { rerender } = render(); + expect(useOrganizationListInView).toHaveBeenCalledWith({ enabled: false }); + + environmentHydrated = true; + rerender(); + expect(useOrganizationListInView).toHaveBeenCalledWith({ enabled: true }); + }); + + it('does not treat reading the active organization as a request to enable them', () => { + render(); + expect(useOrganization).toHaveBeenCalledWith({ + __internal_skipAttemptToEnableOrganizations: true, + }); + }); + + 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-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('identifies the active account by username, then email, then phone, then wallet', () => { + const { rerender } = render(); + expect(screen.getByTestId('active-identifier')).toHaveTextContent('alice'); + + user = { ...(user as FakeUser), username: null }; + rerender(); + expect(screen.getByTestId('active-identifier')).toHaveTextContent('alice@example.com'); + + user = { ...user, primaryEmailAddress: null, primaryPhoneNumber: { phoneNumber: '+15550100' } }; + rerender(); + expect(screen.getByTestId('active-identifier')).toHaveTextContent('+15550100'); + + user = { ...user, primaryPhoneNumber: null, primaryWeb3Wallet: { web3Wallet: '0xabc' } }; + rerender(); + expect(screen.getByTestId('active-identifier')).toHaveTextContent('0xabc'); + }); + + it('describes the active organization whole, and null in personal mode', () => { + const { rerender } = render(); + expect(activeOrganization()).toMatchObject({ + kind: 'membership', + organizationId: 'org_1', + name: 'Acme', + imageUrl: 'https://img/acme', + membersCount: 3, + }); + + organization = null; + rerender(); + expect(activeOrganization()).toBeNull(); + }); + + 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', () => { + 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'); + }); + + // Waiting on the list would open a workspace section under every personal-only account, then + // take it away again. + it('answers hasOrganizations from the user resource before any list has loaded', () => { + userMemberships = list([], 0, false, true); + user = { ...(user as FakeUser), organizationMemberships: [{ id: 'orgmem_1' }] }; + render(); + + expect(screen.getByTestId('orgs-loading')).toHaveTextContent('true'); + 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', + }); + + expect(invitations()[0]).toMatchObject({ + kind: 'invitation', + id: 'inv_1', + organizationId: 'org_3', + organizationName: 'Gamma', + status: 'pending', + }); + }); + + 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'); + 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' }); + }); + + // `null` is Clerk's own name for the personal workspace, and there is no organization for + // `afterSelectOrganizationUrl` to resolve against. + it('selects the personal workspace by clearing the active organization', () => { + render(); + + fireEvent.click(screen.getByText('select-personal')); + expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: undefined }); + }); + + it('redirects the personal workspace to the configured afterSelectPersonalUrl', () => { + const { rerender } = render(); + + fireEvent.click(screen.getByText('select-personal')); + expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: '/u/user_1' }); + + rerender( `/u/${u.username}`} />); + fireEvent.click(screen.getByText('select-personal')); + expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: '/u/alice' }); + }); + + // The two are configured apart, so routing the personal workspace leaves the organizations alone. + it('keeps the personal redirect off the organizations', () => { + render(); + + fireEvent.click(screen.getByText('select-org')); + expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined }); + }); + + // An instance that requires an organization has no personal workspace: clerk-js refuses + // `setActive({ organization: null })` outright there, so offering the switch would offer nothing. + it('reports no personal workspace where the instance forces an organization', () => { + const { rerender } = render(); + expect(screen.getByTestId('hide-personal')).toHaveTextContent('false'); + + forceOrganizationSelection = true; + rerender(); + expect(screen.getByTestId('hide-personal')).toHaveTextContent('true'); + }); + + // An app whose organizations are the whole product withholds it itself. The instance setting is + // the other way in, and neither one can be talked out of it by the other. + it('lets the app withhold the personal workspace on an instance that allows one', () => { + const { rerender } = render(); + expect(screen.getByTestId('hide-personal')).toHaveTextContent('true'); + + forceOrganizationSelection = true; + rerender(); + expect(screen.getByTestId('hide-personal')).toHaveTextContent('true'); + }); + + 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' }); + }); + + // The session switched to can land on a task of its own. A plain `redirectUrl` routes past it and + // strands the account, so the switch hands `setActive` a callback that answers both cases. + it('routes a switched session to its pending task, and to the after-switch URL when it has none', async () => { + render(); + fireEvent.click(screen.getByText('switch')); + + expect(setActive).toHaveBeenCalledWith({ session: 'sess_2', navigate: expect.any(Function) }); + const navigateOnSetActive = setActive.mock.calls[0][0].navigate; + const decorateUrl = vi.fn((url: string) => url); + + await act(async () => { + await navigateOnSetActive({ session: { currentTask: { key: 'choose-organization' } }, decorateUrl }); + }); + expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/sign-in')); + expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/tasks/choose-organization')); + + await act(async () => { + await navigateOnSetActive({ session: { currentTask: null }, decorateUrl }); + }); + expect(navigate).toHaveBeenCalledWith('/after-switch'); + // `redirectUrl` was decorated for us; taking the callback takes the Safari ITP refresh with it. + expect(decorateUrl).toHaveBeenCalledWith('/after-switch'); + }); + + // An instance can restrict who may open an organization, and a user at their creation limit is + // restricted the same way. Offering the action anyway lands them on a page that turns them away. + it('drops create-organization for a user who cannot open one', () => { + const { rerender } = render(); + expect(screen.getByTestId('can-create-org')).toHaveTextContent('true'); + + user = { ...(user as FakeUser), createOrganizationEnabled: false }; + rerender(); + expect(screen.getByTestId('can-create-org')).toHaveTextContent('false'); + }); + + 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'); + }); + + // An instance that has paid the branding off carries none of it, and the environment is the only + // place that answer lives. + it('carries the branding the instance is on, not the branding everyone gets', () => { + render(); + expect(screen.getByTestId('branded')).toHaveTextContent('true'); + + cleanup(); + branded = false; + render(); + expect(screen.getByTestId('branded')).toHaveTextContent('false'); + }); + + // Both profiles open as a modal unless a URL routes instead, which is what the pre-Mosaic + // UserButton and OrganizationSwitcher each do. Nothing navigates, so the page underneath stays. + it('opens the profile modals for manage-account and manage-org', () => { + render(); + + fireEvent.click(screen.getByText('manage-account')); + expect(openUserProfile).toHaveBeenCalled(); + + fireEvent.click(screen.getByText('manage-org')); + expect(openOrganizationProfile).toHaveBeenCalled(); + + expect(navigate).not.toHaveBeenCalled(); + }); + + // An app that mounts the button inside its own dialog or popover puts a portal root around it, and + // the modal has to land there too or it renders behind the surface that opened it. + it('opens the profile modals into the portal root the app configured', () => { + render(); + + fireEvent.click(screen.getByText('manage-account')); + expect(openUserProfile).toHaveBeenCalledWith({ getContainer }); + + fireEvent.click(screen.getByText('manage-org')); + 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 model 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(); + + 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', () => { + render(); + + fireEvent.click(screen.getByText('manage-account')); + expect(navigate).toHaveBeenCalledWith('/account'); + expect(openUserProfile).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText('manage-org')); + expect(openOrganizationProfile).toHaveBeenCalled(); + }); + + it('navigates to an organization profile URL when one is given', () => { + render(); + + fireEvent.click(screen.getByText('manage-org')); + + expect(navigate).toHaveBeenCalledWith('/settings'); + expect(openOrganizationProfile).not.toHaveBeenCalled(); + }); + + // An explicit `navigation` is redundant next to a URL, but it is what the pre-Mosaic props accept, + // so passing both has to resolve the same as passing the URL alone. + it('accepts an explicit navigation mode alongside a URL', () => { + render( + , + ); + + fireEvent.click(screen.getByText('manage-org')); + + expect(navigate).toHaveBeenCalledWith('/settings'); + expect(openOrganizationProfile).not.toHaveBeenCalled(); + }); + + // Invite opens its own modal rather than following manage-org: there is no invite page to route + // to, so an app that routes organization management to its own page still gets the form here. + it('opens the invite-members modal into the portal root, whatever manage-org is routed to', () => { + render(); + + fireEvent.click(screen.getByText('invite-members')); + + expect(openInviteMembers).toHaveBeenCalledWith({ getContainer }); + expect(navigate).not.toHaveBeenCalled(); + }); + + // Creating an organization resolves like the two profiles do: a modal unless a URL routes + // instead. Adding an account always leaves, since signing in cannot happen inside the popover. + it('opens the create-organization modal into the portal root, and navigates for add-account', () => { + render(); + + fireEvent.click(screen.getByText('create-org')); + expect(openCreateOrganization).toHaveBeenCalledWith({ getContainer }); + expect(navigate).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText('add-account')); + expect(navigate).toHaveBeenCalledWith('/sign-in'); + }); + + it('navigates to a create-organization URL when one is given', () => { + render(); + + fireEvent.click(screen.getByText('create-org')); + + expect(navigate).toHaveBeenCalledWith('/new-org'); + expect(openCreateOrganization).not.toHaveBeenCalled(); + }); + + // Without a URL there is nothing to navigate to but Clerk's own page, which is what an explicit + // `navigation` asks for. + it('falls back to the clerk create-organization URL for an explicit navigation mode', () => { + render(); + + fireEvent.click(screen.getByText('create-org')); + + expect(navigate).toHaveBeenCalledWith('/create-org'); + expect(openCreateOrganization).not.toHaveBeenCalled(); + }); + + 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/__tests__/user-button.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx index 2583acccf32..58af0503188 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx @@ -6,28 +6,37 @@ import type { UserButtonController } from '../user-button.controller'; let controller: UserButtonController; +vi.mock('../user-button.model', () => ({ + useUserButtonModel: () => ({ status: 'loading' }), +})); + vi.mock('../user-button.controller', () => ({ useUserButtonController: () => controller, })); -// The container's own job is which of the three controller states renders what, so the surface is +// The custom pages outlive the popup, so the wrapper renders their portals in every state. +vi.mock('../user-button.pages', () => ({ + useUserProfilePages: () => [], + useCustomPages: () => ({ + customPages: undefined, + portals: [ + , + ], + }), +})); + +// The wrapper's own job is which of the three controller states renders what, so the surface is // stubbed out and the view's own tests cover it. vi.mock('../user-button.view', () => ({ - userButtonBusyKeys: { - selectOrganization: () => 'select-organization', - switchSession: () => 'switch-session', - signOutSession: () => 'sign-out-session', - signOutAll: () => 'sign-out-all', - acceptSuggestion: () => 'accept-suggestion', - acceptInvitation: () => 'accept-invitation', - }, UserButtonView: () => , })); function ready(): UserButtonController { return { status: 'ready', - organizationsEnabled: true, renderBranding: true, activeSession: { sessionId: 'sess_1', name: 'Alice Smith', identifier: 'alice@example.com' }, activeOrganization: null, @@ -68,8 +77,23 @@ describe('UserButton', () => { expect(screen.queryByTestId('fallback')).not.toBeInTheDocument(); }); - it('renders nothing while loading when no fallback is given', () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); + it('renders no fallback while loading when none is given', () => { + render(); + expect(screen.queryByTestId('fallback')).not.toBeInTheDocument(); + expect(screen.queryByTestId('view')).not.toBeInTheDocument(); + }); + + // The profile can be open in clerk-js's own root while the button itself has nothing to render. + it('keeps the custom page portals mounted in every state', () => { + const { rerender } = render(); + expect(screen.getByTestId('portal')).toBeInTheDocument(); + + controller = { status: 'hidden' }; + rerender(); + expect(screen.getByTestId('portal')).toBeInTheDocument(); + + controller = ready(); + rerender(); + expect(screen.getByTestId('portal')).toBeInTheDocument(); }); }); 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 336e6167a66..947de532aed 100644 --- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx @@ -1,296 +1,206 @@ -import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks'; -import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user'; -import { useClerk, useOrganization, usePortalRoot, useSession, useUser } from '@clerk/shared/react'; -import type { CustomPage, OrganizationResource, UserResource } from '@clerk/shared/types'; +import { useSpinDelay } from '../hooks/useSpinDelay'; +import { setup } from '../machine/setup'; +import { useMachine } from '../machine/useMachine'; +import type { UserButtonModel } from './user-button.model'; +import type { UserButtonMenuProps, UserButtonModeProps } from './user-button.types'; +import type { UserButtonProps as UserButtonViewProps, UserButtonTriggerProps } from './user-button.view'; +import { userButtonBusyKeys } from './user-button.view'; + +/** The model once Clerk has answered, which is the only shape an action can start from. */ +export type UserButtonReadyModel = Extract; + +interface UserButtonMachineContext { + /** Which action is currently pending. */ + pendingKey: string | null; + /** + * The model the action started from. `setActive` swaps the active organization while its + * promise is still in flight, so the live model would rearrange the popup mid-action. + * The view renders this instead until the action settles. + */ + frozen: UserButtonReadyModel | null; + /** Injected per-action effect — the model callback the clicked row runs. */ + run: () => Promise; + /** Whether succeeding ends the interaction, and the popup with it. */ + closeOnSuccess: boolean; +} -import { populateParamFromObject } from '../../contexts/utils'; -import { useOrganizationListInView } from '../../hooks/useOrganizationListInView'; -import { useMosaicEnvironment } from '../hooks/useMosaicEnvironment'; -import { useMosaicRouter } from '../hooks/useMosaicRouter'; -import type { - UserButtonBrandingProps, - UserButtonCallbacks, - UserButtonData, - UserButtonInvitation, - UserButtonMembership, - UserButtonSession, - UserButtonSuggestion, -} from './user-button.types'; +type UserButtonMachineEvent = + | { type: 'OPEN' } + | { type: 'CLOSE' } + | { + type: 'RUN'; + key: string; + frozen: UserButtonReadyModel; + run: () => Promise; + closeOnSuccess: boolean; + }; + +const { createMachine, assign, fromPromise } = setup(); + +const settled = { pendingKey: null, frozen: null }; + +const userButtonMachine = createMachine({ + id: 'userButton', + initial: 'closed', + context: { + pendingKey: null, + frozen: null, + run: () => Promise.resolve(), + closeOnSuccess: false, + }, + states: { + closed: { + on: { OPEN: 'open' }, + }, + open: { + on: { + CLOSE: 'closed', + RUN: { + target: 'busy', + actions: assign((_, event) => ({ + pendingKey: event.key, + frozen: event.frozen, + run: event.run, + closeOnSuccess: event.closeOnSuccess, + })), + }, + }, + }, + // Reached only from `open`, so a busy popup that is not open is unrepresentable, and RUN going + // unhandled here is what stops a second action starting while one is in flight. Dismissing the + // popup abandons the action: the request finishes, but nothing is left for its result to land in. + busy: { + on: { CLOSE: { target: 'closed', actions: assign(() => settled) } }, + invoke: fromPromise(context => context.run(), { + onDone: [ + { target: 'closed', guard: context => context.closeOnSuccess, actions: assign(() => settled) }, + { target: 'open', actions: assign(() => settled) }, + ], + // The popup stays up on a failure so the row can be clicked again. Nothing reports what went + // wrong yet; the error surface is its own change, and carrying a message before one exists + // would mean shipping an untranslated string nobody reads. + onError: { target: 'open', actions: assign(() => settled) }, + }), + }, + }, +}); -// Promise-returning so the container can drive busy state. Navigation callbacks stay fire-and-forget. -interface UserButtonAsyncCallbacks { - onSelectOrganization?: (organizationId: string | null) => 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 UserButtonControllerOptions = Pick & UserButtonMenuProps; export type UserButtonController = | { status: 'loading' } | { status: 'hidden' } - | (UserButtonData & - Omit & - UserButtonAsyncCallbacks & - UserButtonBrandingProps & { - status: 'ready'; - /** Whether the instance has organizations turned on at all. False forces the button to `user` mode. */ - organizationsEnabled: boolean; - }); - -// Mirrors ``: a URL, a `:token` template resolved against the entity, or a builder. -type AfterSelectUrl = ((entity: T) => string) | string; - -/** A URL is the whole opt-in to navigation, and `modal` forbids one, so the pair cannot contradict itself. */ -type UserProfileMode = - | { userProfileUrl: string; userProfileMode?: 'navigation' } - | { userProfileUrl?: never; userProfileMode?: 'modal' }; - -type OrganizationProfileMode = - | { organizationProfileUrl: string; organizationProfileMode?: 'navigation' } - | { organizationProfileUrl?: never; organizationProfileMode?: 'modal' }; - -type CreateOrganizationMode = - | { createOrganizationUrl: string; createOrganizationMode?: 'navigation' } - | { createOrganizationUrl?: never; createOrganizationMode?: 'modal' }; - -export type UserButtonControllerOptions = UserProfileMode & - OrganizationProfileMode & - CreateOrganizationMode & { - afterSelectOrganizationUrl?: AfterSelectUrl; - /** Where selecting the personal workspace lands. Resolved against the user, not an organization. */ - afterSelectPersonalUrl?: AfterSelectUrl; - /** - * Leaves the personal workspace out. An instance that forces organization selection withholds it - * either way, so this cannot opt back in. - */ - hidePersonal?: boolean; - }; - -function resolveAfterSelectUrl(config: AfterSelectUrl | undefined, entity: T): string | undefined { - if (typeof config === 'function') { - return config(entity); - } - if (config) { - return populateParamFromObject({ urlWithParam: config, entity }); - } - return undefined; -} - -/** Opens the modal unless a URL routes instead. An explicit mode wins; a URL on its own means navigation. */ -function openOrNavigate({ - url, - mode, - openModal, - buildUrl, - navigate, -}: { - url: string | undefined; - mode: 'navigation' | 'modal' | undefined; - openModal: () => void; - buildUrl: () => string; - navigate: (to: string) => unknown; -}): () => void { - const resolved = mode ?? (url ? 'navigation' : 'modal'); - return resolved === 'navigation' ? () => void navigate(url ?? buildUrl()) : () => openModal(); -} - -const INVITE_MEMBERS_PERMISSION = 'org:sys_memberships:manage'; - -function displayName(user: UserResource): string { - return getFullName(user) || getIdentifier(user); -} - -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, - name: displayName(user), - identifier: getIdentifier(user), - imageUrl: user.imageUrl, - }; -} + | ({ status: 'ready' } & Omit); /** - * @param userProfileCustomPages - The consumer's custom pages, already bridged into clerk-js's - * DOM-callback form. The container owns that conversion because it is the layer that can render - * the portals behind it, so they arrive here ready to forward and stay out of the public options. + * The controller is the layer between the component (view) and the external world (model). + * It represents the local state and wraps model actions in order to handle pending states, + * keep the UI stable while an action is ongoing, close the popup on completed actions + * when appropriate, etc. */ export function useUserButtonController( - options?: UserButtonControllerOptions, - userProfileCustomPages?: CustomPage[], + model: UserButtonModel, + 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(); - // The modal must portal into the app's own dialog root, or it renders behind the surface that opened it. - const getContainer = usePortalRoot(); - const environment = useMosaicEnvironment(); - - const manageAccount = openOrNavigate({ - url: options?.userProfileUrl, - mode: options?.userProfileMode, - openModal: () => clerk.openUserProfile({ getContainer, customPages: userProfileCustomPages }), - buildUrl: () => clerk.buildUserProfileUrl(), - navigate: router.navigate, + const { mode: requestedMode, modePriority, customMenuItems, menuItemOrder } = options; + // The popover's open state and the one action in flight are the same flow: an action that ends the + // interaction closes the surface, so they settle together or not at all. + const [{ value, context }, send] = useMachine(userButtonMachine); + + // Every action here is a network round trip, so we can start the + // pending state immediately, we use this for the minDuration + const displayPendingKey = useSpinDelay(context.pendingKey, { + delay: 0, + minDuration: context.closeOnSuccess ? 0 : undefined, }); - const manageOrganization = openOrNavigate({ - url: options?.organizationProfileUrl, - mode: options?.organizationProfileMode, - openModal: () => clerk.openOrganizationProfile({ getContainer }), - buildUrl: () => clerk.buildOrganizationProfileUrl(), - navigate: router.navigate, - }); - - const createOrganization = openOrNavigate({ - url: options?.createOrganizationUrl, - mode: options?.createOrganizationMode, - openModal: () => clerk.openCreateOrganization({ getContainer }), - buildUrl: () => clerk.buildCreateOrganizationUrl(), - navigate: router.navigate, - }); - - // These all affect layout, so wait for every one and avoid a reshuffle. - if (!isUserLoaded || !isSessionLoaded || !isOrgLoaded || !environment) { - return { status: 'loading' }; + if (model.status !== 'ready') { + return { status: model.status }; } - if (!user || !session) { - return { status: 'hidden' }; - } - - const { displayConfig, authConfig, organizationSettings } = environment; - // clerk-js refuses `setActive({ organization: null })` when selection is forced, so there is no way back. - const { enabled: organizationsEnabled, forceOrganizationSelection } = organizationSettings; - const { singleSessionMode } = authConfig; - - const canInviteMembers = session.checkAuthorization({ permission: INVITE_MEMBERS_PERMISSION }) ?? false; - const membershipData = userMemberships.data ?? []; - const suggestionData = userSuggestions.data ?? []; - const invitationData = userInvitations.data ?? []; + const close = () => send({ type: 'CLOSE' }); - const memberships: UserButtonMembership[] = membershipData.map(m => toMembership(m.organization)); - - 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, - })); - - // Accepting is all a row offers, so a revoked or expired invitation has nothing to show. - 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, + const menuItems = customMenuItems?.map(item => + item.href === undefined + ? { + ...item, + onClick: () => { + // Always close the menu for custom actions + close(); + item.onClick(); }, - ] - : [], + } + : item, ); - // Organization requests are scoped to the active session, so another account's workspaces are unknowable. - const additionalSessions: UserButtonSession[] = (clerk.client?.signedInSessions ?? []).flatMap(s => { - const sessionUser = s.user; - if (!sessionUser || s.id === session.id) { - return []; - } - return [toSession(s.id, sessionUser)]; - }); - - const afterSelectUrl = (organizationId: string | null): string | undefined => { - if (!organizationId) { - return resolveAfterSelectUrl(options?.afterSelectPersonalUrl, user); - } - const selected = membershipData.find(m => m.organization.id === organizationId)?.organization; - return selected ? resolveAfterSelectUrl(options?.afterSelectOrganizationUrl, selected) : undefined; - }; + // Wrapper to tie a callback into the machine + const runAction = ( + keyFor: (...args: Args) => string, + fn: ((...args: Args) => void | Promise) | undefined, + closeOnSuccess = false, + ) => + fn + ? (...args: Args) => + send({ + type: 'RUN', + key: keyFor(...args), + frozen: model, + run: async () => fn(...args), + closeOnSuccess, + }) + : undefined; + + // A callback that wraps a callback so it always closes the popup when done + const handOff = (fn: (() => void) | undefined) => + fn + ? () => { + close(); + fn(); + } + : undefined; + + // Rendering the model the action froze on holds the popup still while it runs; the result + // lands in one step when it settles. See `frozen` in the machine for why. + const { + status: _status, + organizationsEnabled, + onSelectOrganization, + onSwitchSession, + onSignOutSession, + onSignOutAll, + onAcceptSuggestion, + onAcceptInvitation, + onManageAccount, + onManageOrganization, + onInviteMembers, + onCreateOrganization, + onAddAccount, + ...data + } = context.frozen ?? model; + + // Force user mode if organizations are disabled + const mode = model.organizationsEnabled ? requestedMode : 'user'; return { status: 'ready', - organizationsEnabled, - renderBranding: displayConfig.branded, - activeSession: toSession(session.id, user), - activeOrganization: organization ? toMembership(organization) : null, - // The user resource settles this before the paginated list answers; the count covers a stale resource. - hasOrganizations: user.organizationMemberships.length > 0 || (userMemberships.count ?? 0) > 0, - hidePersonal: forceOrganizationSelection || (options?.hidePersonal ?? false), - // Only true before the first page lands, which is the one window where empty and pending look alike. - organizationsLoading: userMemberships.isLoading || userInvitations.isLoading || userSuggestions.isLoading, - memberships, - suggestions, - invitations, - additionalSessions, - paging: { - ref, - hasMore: Boolean(userMemberships.hasNextPage || userInvitations.hasNextPage || userSuggestions.hasNextPage), - }, - onSelectOrganization: organizationId => - clerk.setActive({ organization: organizationId, redirectUrl: afterSelectUrl(organizationId) }), - // The session switched to can carry a task of its own, and a plain `redirectUrl` routes past it. - // App-level `taskUrls` outrank this callback, so it only answers for an app that set none. - onSwitchSession: sessionId => - clerk.setActive({ - session: sessionId, - navigate: async ({ session, decorateUrl }) => { - const task = session.currentTask; - if (task) { - await router.navigate(buildTaskUrl(task, { base: clerk.buildSignInUrl() })); - return; - } - // `redirectUrl` decorated for us; taking the callback takes the Safari ITP refresh with it. - await router.navigate(decorateUrl(displayConfig.afterSwitchSessionUrl)); - }, - }), - onSignOutSession: sessionId => - clerk.signOut({ - sessionId, - // Other accounts stay signed in, so this is a single sign out rather than a full one. - redirectUrl: - additionalSessions.length > 0 ? clerk.buildAfterMultiSessionSingleSignOutUrl() : clerk.buildAfterSignOutUrl(), - }), - // Single-session apps cannot hold a second account, so both actions are meaningless there. - onSignOutAll: singleSessionMode ? undefined : () => clerk.signOut({ redirectUrl: clerk.buildAfterSignOutUrl() }), - onManageAccount: manageAccount, - onManageOrganization: manageOrganization, - // Invite has no page of its own to route to, so it opens its modal even when management is routed. - onInviteMembers: canInviteMembers ? () => clerk.openInviteMembers({ getContainer }) : undefined, - // Covers both restricted instances and users at their creation limit. - onCreateOrganization: user.createOrganizationEnabled ? createOrganization : undefined, - 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?.()); - }, - // Accepting joins the organization, so memberships are stale too. A suggestion joins nothing. - onAcceptInvitation: invitationId => { - const invitation = invitationData.find(i => i.id === invitationId); - return Promise.resolve(invitation?.accept()).finally(() => { - void userInvitations.revalidate?.(); - void userMemberships.revalidate?.(); - }); - }, + ...data, + mode, + modePriority, + customMenuItems: menuItems, + menuItemOrder, + open: value !== 'closed', + onOpenChange: next => send(next ? { type: 'OPEN' } : { type: 'CLOSE' }), + pendingKey: displayPendingKey, + onSelectOrganization: runAction(userButtonBusyKeys.selectOrganization, onSelectOrganization, true), + onSwitchSession: runAction(userButtonBusyKeys.switchSession, onSwitchSession), + onSignOutSession: runAction(userButtonBusyKeys.signOutSession, onSignOutSession), + onSignOutAll: runAction(userButtonBusyKeys.signOutAll, onSignOutAll), + onAcceptSuggestion: runAction(userButtonBusyKeys.acceptSuggestion, onAcceptSuggestion), + onAcceptInvitation: runAction(userButtonBusyKeys.acceptInvitation, onAcceptInvitation), + onManageAccount: handOff(onManageAccount), + onManageOrganization: handOff(onManageOrganization), + onInviteMembers: handOff(onInviteMembers), + onCreateOrganization: handOff(onCreateOrganization), + onAddAccount: handOff(onAddAccount), }; } diff --git a/packages/ui/src/mosaic/user-button/user-button.machine.ts b/packages/ui/src/mosaic/user-button/user-button.machine.ts deleted file mode 100644 index 5bf1509ab56..00000000000 --- a/packages/ui/src/mosaic/user-button/user-button.machine.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { setup } from '../machine/setup'; -import type { UserButtonController } from './user-button.controller'; - -/** The controller once Clerk has answered, which is the only shape an action can start from. */ -export type UserButtonReadyController = Extract; - -export interface UserButtonMachineContext { - /** The affordance that owns the action in flight: it spins, and every other one stands down. */ - pendingKey: string | null; - /** - * The controller the action started from. `setActive` swaps the active organization while its - * promise is still in flight, so the live controller would rearrange the popup mid-action: the - * header renaming itself, the check jumping rows, Invite coming and going as the permission is - * re-read. The view renders this instead until the action settles. - */ - frozen: UserButtonReadyController | null; - /** Injected per-action effect — the controller callback the clicked row runs. */ - run: () => Promise; - /** Whether succeeding ends the interaction, and the popup with it. */ - closeOnSuccess: boolean; -} - -export type UserButtonMachineEvent = - | { type: 'OPEN' } - | { type: 'CLOSE' } - | { - type: 'RUN'; - key: string; - frozen: UserButtonReadyController; - run: () => Promise; - closeOnSuccess: boolean; - }; - -const { createMachine, assign, fromPromise } = setup(); - -const settled = { pendingKey: null, frozen: null }; - -export const userButtonMachine = createMachine({ - id: 'userButton', - initial: 'closed', - context: { - pendingKey: null, - frozen: null, - run: () => Promise.resolve(), - closeOnSuccess: false, - }, - states: { - closed: { - on: { OPEN: 'open' }, - }, - open: { - on: { - CLOSE: 'closed', - RUN: { - target: 'busy', - actions: assign((_, event) => ({ - pendingKey: event.key, - frozen: event.frozen, - run: event.run, - closeOnSuccess: event.closeOnSuccess, - })), - }, - }, - }, - // Reached only from `open`, so a busy popup that is not open is unrepresentable, and RUN going - // unhandled here is what stops a second action starting while one is in flight. Dismissing the - // popup abandons the action: the request finishes, but nothing is left for its result to land in. - busy: { - on: { CLOSE: { target: 'closed', actions: assign(() => settled) } }, - invoke: fromPromise(context => context.run(), { - onDone: [ - { target: 'closed', guard: context => context.closeOnSuccess, actions: assign(() => settled) }, - { target: 'open', actions: assign(() => settled) }, - ], - // The popup stays up on a failure so the row can be clicked again. Nothing reports what went - // wrong yet; the error surface is its own change, and carrying a message before one exists - // would mean shipping an untranslated string nobody reads. - onError: { target: 'open', actions: assign(() => settled) }, - }), - }, - }, -}); diff --git a/packages/ui/src/mosaic/user-button/user-button.model.tsx b/packages/ui/src/mosaic/user-button/user-button.model.tsx new file mode 100644 index 00000000000..4539add1422 --- /dev/null +++ b/packages/ui/src/mosaic/user-button/user-button.model.tsx @@ -0,0 +1,303 @@ +import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks'; +import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user'; +import { useClerk, useOrganization, usePortalRoot, useSession, useUser } from '@clerk/shared/react'; +import type { CustomPage, 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 { + UserButtonBrandingProps, + UserButtonCallbacks, + UserButtonData, + UserButtonInvitation, + UserButtonMembership, + UserButtonSession, + UserButtonSuggestion, +} from './user-button.types'; + +// Promise-returning so the controller can drive busy state. Navigation callbacks stay fire-and-forget. +interface UserButtonAsyncCallbacks { + onSelectOrganization?: (organizationId: string | null) => 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 UserButtonModel = + | { status: 'loading' } + | { status: 'hidden' } + | (UserButtonData & + Omit & + UserButtonAsyncCallbacks & + UserButtonBrandingProps & { + status: 'ready'; + /** Whether the instance has organizations turned on at all. False forces the button to `user` mode. */ + organizationsEnabled: boolean; + }); + +// Mirrors ``: a URL, a `:token` template resolved against the entity, or a builder. +type AfterSelectUrl = ((entity: T) => string) | string; + +/** A URL is the whole opt-in to navigation, and `modal` forbids one, so the pair cannot contradict itself. */ +type UserProfileMode = + | { userProfileUrl: string; userProfileMode?: 'navigation' } + | { userProfileUrl?: never; userProfileMode?: 'modal' }; + +type OrganizationProfileMode = + | { organizationProfileUrl: string; organizationProfileMode?: 'navigation' } + | { organizationProfileUrl?: never; organizationProfileMode?: 'modal' }; + +type CreateOrganizationMode = + | { createOrganizationUrl: string; createOrganizationMode?: 'navigation' } + | { createOrganizationUrl?: never; createOrganizationMode?: 'modal' }; + +export type UserButtonModelOptions = UserProfileMode & + OrganizationProfileMode & + CreateOrganizationMode & { + afterSelectOrganizationUrl?: AfterSelectUrl; + /** Where selecting the personal workspace lands. Resolved against the user, not an organization. */ + afterSelectPersonalUrl?: AfterSelectUrl; + /** + * Leaves the personal workspace out. An instance that forces organization selection withholds it + * either way, so this cannot opt back in. + */ + hidePersonal?: boolean; + }; + +function resolveAfterSelectUrl(config: AfterSelectUrl | undefined, entity: T): string | undefined { + if (typeof config === 'function') { + return config(entity); + } + if (config) { + return populateParamFromObject({ urlWithParam: config, entity }); + } + return undefined; +} + +/** Opens the modal unless a URL routes instead. An explicit mode wins; a URL on its own means navigation. */ +function openOrNavigate({ + url, + mode, + openModal, + buildUrl, + navigate, +}: { + url: string | undefined; + mode: 'navigation' | 'modal' | undefined; + openModal: () => void; + buildUrl: () => string; + navigate: (to: string) => unknown; +}): () => void { + const resolved = mode ?? (url ? 'navigation' : 'modal'); + return resolved === 'navigation' ? () => void navigate(url ?? buildUrl()) : () => openModal(); +} + +const INVITE_MEMBERS_PERMISSION = 'org:sys_memberships:manage'; + +function displayName(user: UserResource): string { + return getFullName(user) || getIdentifier(user); +} + +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, + name: displayName(user), + identifier: getIdentifier(user), + imageUrl: user.imageUrl, + }; +} + +/** + * @param userProfileCustomPages - The consumer's custom pages, already bridged into clerk-js's + * DOM-callback form. The wrapper owns that conversion because it is the layer that can render + * the portals behind it, so they arrive here ready to forward and stay out of the public options. + */ +export function useUserButtonModel( + options?: UserButtonModelOptions, + userProfileCustomPages?: CustomPage[], +): UserButtonModel { + const { isLoaded: isUserLoaded, user } = useUser(); + const { isLoaded: isSessionLoaded, session } = useSession(); + // The active org names the trigger. That is not a request to turn Organizations on. + const { isLoaded: isOrgLoaded, organization } = useOrganization({ + __internal_skipAttemptToEnableOrganizations: true, + }); + const clerk = useClerk(); + const router = useMosaicRouter(); + // The modal must portal into the app's own dialog root, or it renders behind the surface that opened it. + const getContainer = usePortalRoot(); + const environment = useMosaicEnvironment(); + // Don't fetch orgsLists until we know orgs are enabled. + // This wont delay rendering of the trigger, or even the popup shell, since the "ready" status + // does not depend on this. + const { userMemberships, userInvitations, userSuggestions, ref } = useOrganizationListInView({ + enabled: Boolean(environment?.organizationSettings.enabled), + }); + + const manageAccount = openOrNavigate({ + url: options?.userProfileUrl, + mode: options?.userProfileMode, + openModal: () => clerk.openUserProfile({ getContainer, customPages: userProfileCustomPages }), + buildUrl: () => clerk.buildUserProfileUrl(), + navigate: router.navigate, + }); + + const manageOrganization = openOrNavigate({ + url: options?.organizationProfileUrl, + mode: options?.organizationProfileMode, + openModal: () => clerk.openOrganizationProfile({ getContainer }), + buildUrl: () => clerk.buildOrganizationProfileUrl(), + navigate: router.navigate, + }); + + const createOrganization = openOrNavigate({ + url: options?.createOrganizationUrl, + mode: options?.createOrganizationMode, + openModal: () => clerk.openCreateOrganization({ getContainer }), + buildUrl: () => clerk.buildCreateOrganizationUrl(), + navigate: router.navigate, + }); + + // These all affect layout, so wait for every one and avoid a reshuffle. + if (!isUserLoaded || !isSessionLoaded || !isOrgLoaded || !environment) { + return { status: 'loading' }; + } + + if (!user || !session) { + return { status: 'hidden' }; + } + + const { displayConfig, authConfig, organizationSettings } = environment; + // clerk-js refuses `setActive({ organization: null })` when selection is forced, so there is no way back. + const { enabled: organizationsEnabled, forceOrganizationSelection } = organizationSettings; + const { singleSessionMode } = authConfig; + + 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 => toMembership(m.organization)); + + 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, + })); + + // Accepting is all a row offers, so a revoked or expired invitation has nothing to show. + 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 active session, so another account's workspaces are unknowable. + const additionalSessions: UserButtonSession[] = (clerk.client?.signedInSessions ?? []).flatMap(s => { + const sessionUser = s.user; + if (!sessionUser || s.id === session.id) { + return []; + } + return [toSession(s.id, sessionUser)]; + }); + + const afterSelectUrl = (organizationId: string | null): string | undefined => { + if (!organizationId) { + return resolveAfterSelectUrl(options?.afterSelectPersonalUrl, user); + } + const selected = membershipData.find(m => m.organization.id === organizationId)?.organization; + return selected ? resolveAfterSelectUrl(options?.afterSelectOrganizationUrl, selected) : undefined; + }; + + return { + status: 'ready', + organizationsEnabled, + renderBranding: displayConfig.branded, + activeSession: toSession(session.id, user), + activeOrganization: organization ? toMembership(organization) : null, + // The user resource settles this before the paginated list answers; the count covers a stale resource. + hasOrganizations: user.organizationMemberships.length > 0 || (userMemberships.count ?? 0) > 0, + hidePersonal: forceOrganizationSelection || (options?.hidePersonal ?? false), + // Only true before the first page lands, which is the one window where empty and pending look alike. + organizationsLoading: userMemberships.isLoading || userInvitations.isLoading || userSuggestions.isLoading, + memberships, + suggestions, + invitations, + additionalSessions, + paging: { + ref, + hasMore: Boolean(userMemberships.hasNextPage || userInvitations.hasNextPage || userSuggestions.hasNextPage), + }, + onSelectOrganization: organizationId => + clerk.setActive({ organization: organizationId, redirectUrl: afterSelectUrl(organizationId) }), + // The session switched to can carry a task of its own, and a plain `redirectUrl` routes past it. + // App-level `taskUrls` outrank this callback, so it only answers for an app that set none. + onSwitchSession: sessionId => + clerk.setActive({ + session: sessionId, + navigate: async ({ session, decorateUrl }) => { + const task = session.currentTask; + if (task) { + await router.navigate(buildTaskUrl(task, { base: clerk.buildSignInUrl() })); + return; + } + // `redirectUrl` decorated for us; taking the callback takes the Safari ITP refresh with it. + await router.navigate(decorateUrl(displayConfig.afterSwitchSessionUrl)); + }, + }), + onSignOutSession: sessionId => + clerk.signOut({ + sessionId, + // Other accounts stay signed in, so this is a single sign out rather than a full one. + redirectUrl: + additionalSessions.length > 0 ? clerk.buildAfterMultiSessionSingleSignOutUrl() : clerk.buildAfterSignOutUrl(), + }), + // Single-session apps cannot hold a second account, so both actions are meaningless there. + onSignOutAll: singleSessionMode ? undefined : () => clerk.signOut({ redirectUrl: clerk.buildAfterSignOutUrl() }), + onManageAccount: manageAccount, + onManageOrganization: manageOrganization, + // Invite has no page of its own to route to, so it opens its modal even when management is routed. + onInviteMembers: canInviteMembers ? () => clerk.openInviteMembers({ getContainer }) : undefined, + // Covers both restricted instances and users at their creation limit. + onCreateOrganization: user.createOrganizationEnabled ? createOrganization : undefined, + 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?.()); + }, + // Accepting joins the organization, so memberships are stale too. A suggestion joins nothing. + onAcceptInvitation: invitationId => { + const invitation = invitationData.find(i => i.id === invitationId); + return Promise.resolve(invitation?.accept()).finally(() => { + void userInvitations.revalidate?.(); + void userMemberships.revalidate?.(); + }); + }, + }; +} diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx index a4a79f994c1..60f2094ceb3 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -2,16 +2,14 @@ import type { ReactElement, ReactNode } from 'react'; -import { useSpinDelay } from '../hooks/useSpinDelay'; -import { useMachine } from '../machine/useMachine'; -import type { UserButtonControllerOptions } from './user-button.controller'; import { useUserButtonController } from './user-button.controller'; -import { userButtonMachine } from './user-button.machine'; +import type { UserButtonModelOptions } from './user-button.model'; +import { useUserButtonModel } from './user-button.model'; import type { CustomProfileItem, UserProfilePageId } from './user-button.pages'; import { useCustomPages, useUserProfilePages } from './user-button.pages'; import type { UserButtonMenuProps, UserButtonModeProps } from './user-button.types'; import type { UserButtonTriggerProps } from './user-button.view'; -import { userButtonBusyKeys, UserButtonView } from './user-button.view'; +import { UserButtonView } from './user-button.view'; /** Configures the UserProfile this button opens. */ export interface UserButtonUserProfileProps { @@ -25,22 +23,18 @@ export interface UserButtonUserProfileProps { pageOrder?: (UserProfilePageId | (string & {}))[]; } -/** - * Everything `` takes: where its profile surfaces open (`UserButtonControllerOptions`), - * what the trigger shows (`UserButtonTriggerProps`), the app's own rows at the foot of the menu - * (`UserButtonMenuProps`), and the profile it opens (`UserButtonUserProfileProps`). - */ -export type UserButtonProps = UserButtonControllerOptions & +/** Everything `` takes: profile routing, trigger content, the app's own menu rows, and the profile it opens. */ +export type UserButtonProps = UserButtonModelOptions & UserButtonTriggerProps & UserButtonMenuProps & UserButtonModeProps & { + userProfileProps?: UserButtonUserProfileProps; /** * Stands in while Clerk is still answering, so the space the button will take is held rather * than appearing under whatever is beside it. Dropped once nobody is signed in, since that is * an answer and not a wait. */ fallback?: ReactNode; - userProfileProps?: UserButtonUserProfileProps; }; /** @@ -107,7 +101,7 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { const { renderTriggerLabel, renderTriggerBadge, - mode: requestedMode, + mode, modePriority, userProfileProps, customMenuItems, @@ -117,126 +111,38 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { } = props; // The profile opens in clerk-js's own React root, so its custom pages reach it as portals rendered // from here. They have to outlive the popover that opened it, and the button's own data with it, - // which is why they hang off the container rather than anything the popover renders. + // which is why they hang off the wrapper rather than anything the popover renders. const builtInPages = useUserProfilePages(); const { customPages, portals } = useCustomPages({ items: userProfileProps?.customPages, order: userProfileProps?.pageOrder, builtInPages, }); - const controller = useUserButtonController(options, customPages); - // The popover's open state and the one action in flight are the same flow: an action that ends the - // interaction closes the surface, so they settle together or not at all. - const [{ value, context }, send] = useMachine(userButtonMachine); + const model = useUserButtonModel(options, customPages); + const controller = useUserButtonController(model, { mode, modePriority, customMenuItems, menuItemOrder }); - // Every action here is a network round trip, so there is nothing to debounce and the click gets - // its spinner at once. The hook is still what steadies it, holding it up long enough to read. - const displayPendingKey = useSpinDelay(context.pendingKey, { - delay: 0, - // Holding it steadies a surface still on screen. An action that closes the popup leaves none, - // so the hold would outlive it and stiffen the next open instead. - minDuration: context.closeOnSuccess ? 0 : undefined, - }); - - // If controller ever goes back into loading, we want to preserve the portals if (controller.status === 'loading') { - return <>{fallback}{portals}; + return ( + <> + {fallback} + {portals} + + ); } - if (controller.status !== 'ready') { + // Signed out is an answer, so the placeholder goes too rather than promising a button. + if (controller.status === 'hidden') { return <>{portals}; } - const close = () => send({ type: 'CLOSE' }); - - // Whatever the app's action opens takes over from here, so the popover goes with it. Links navigate away. - const menuItems = customMenuItems?.map(item => - item.href === undefined - ? { - ...item, - onClick: () => { - close(); - item.onClick(); - }, - } - : item, - ); - - // Only an action that ends the interaction closes the popover; the rest resolve into it. - const runAction = ( - keyFor: (...args: Args) => string, - fn: ((...args: Args) => void | Promise) | undefined, - closeOnSuccess = false, - ) => - fn - ? (...args: Args) => - send({ - type: 'RUN', - key: keyFor(...args), - frozen: controller, - run: async () => fn(...args), - closeOnSuccess, - }) - : undefined; - - // A modal or another page takes over from here, so there is nothing left for the popover to show; - // left up, it would sit over the very surface it just opened. - const handOff = (fn: (() => void) | undefined) => - fn - ? () => { - close(); - fn(); - } - : undefined; - - // Rendering the controller the action froze on holds the popup still while it runs; the result - // lands in one step when it settles. See `frozen` in the machine for why. - const { - status: _status, - organizationsEnabled, - onSelectOrganization, - onSwitchSession, - onSignOutSession, - onSignOutAll, - onAcceptSuggestion, - onAcceptInvitation, - onManageAccount, - onManageOrganization, - onInviteMembers, - onCreateOrganization, - onAddAccount, - ...data - } = context.frozen ?? controller; - - // Organizations off at the instance leaves nothing for an organization surface to lead with or - // list, so the button is the account's whatever mode asked for. clerk-js withholds its own - // `` at the mount boundary; nothing mounts this one, so the gate lives here. - const mode = organizationsEnabled ? requestedMode : 'user'; + const { status: _status, ...viewController } = controller; return ( <> send(next ? { type: 'OPEN' } : { type: 'CLOSE' })} - pendingKey={displayPendingKey} - onSelectOrganization={runAction(userButtonBusyKeys.selectOrganization, onSelectOrganization, true)} - onSwitchSession={runAction(userButtonBusyKeys.switchSession, onSwitchSession)} - onSignOutSession={runAction(userButtonBusyKeys.signOutSession, onSignOutSession)} - onSignOutAll={runAction(userButtonBusyKeys.signOutAll, onSignOutAll)} - onAcceptSuggestion={runAction(userButtonBusyKeys.acceptSuggestion, onAcceptSuggestion)} - onAcceptInvitation={runAction(userButtonBusyKeys.acceptInvitation, onAcceptInvitation)} - onManageAccount={handOff(onManageAccount)} - onManageOrganization={handOff(onManageOrganization)} - onInviteMembers={handOff(onInviteMembers)} - onCreateOrganization={handOff(onCreateOrganization)} - onAddAccount={handOff(onAddAccount)} /> {portals} diff --git a/packages/ui/src/mosaic/user-button/user-button.types.ts b/packages/ui/src/mosaic/user-button/user-button.types.ts index b07594c8b9f..a8a89242481 100644 --- a/packages/ui/src/mosaic/user-button/user-button.types.ts +++ b/packages/ui/src/mosaic/user-button/user-button.types.ts @@ -1,8 +1,8 @@ import type { ReactNode } from 'react'; // ─── Data contract ────────────────────────────────────────────────────────── -// Session-backed, discriminated resource rows. 1:1 with `useUserButtonController()`'s output, so the -// controller and the view agree on a shape neither one owns. +// Session-backed, discriminated resource rows. 1:1 with `useUserButtonModel()`'s output, so the +// model and the view agree on a shape neither one owns. export interface UserButtonSession { sessionId: string; diff --git a/packages/ui/src/mosaic/user-button/user-button.view.tsx b/packages/ui/src/mosaic/user-button/user-button.view.tsx index c7906c94206..9568e439b2d 100644 --- a/packages/ui/src/mosaic/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -39,7 +39,7 @@ import type { import { applyOrder } from './user-button.utils'; // The data contract, the mode flags, and the menu item shapes live in `user-button.types`; they are -// what the controller and the view agree on, so neither file owns them. +// what the model and the view agree on, so neither file owns them. export type * from './user-button.types'; /** From cc37beada34b33a609eaef730e893e558bc328c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 26 Aug 2026 15:34:54 +0200 Subject: [PATCH 60/71] Prevent UserButton opening again on sign-in after sign-out --- .../user-button.integration.test.tsx | 48 ++++++++++++++++--- .../user-button/user-button.controller.tsx | 9 +++- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx index 0e38e876e03..54e9ce02b6a 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx @@ -178,17 +178,33 @@ afterEach(() => { vi.clearAllMocks(); }); -function renderUserButton(props: UserButtonProps = {}) { - return render( +function tree(props: UserButtonProps = {}) { + return ( {/* The button portals its popup out, so this host holds only what it renders in place. */}
-
, + ); } +function renderUserButton(props: UserButtonProps = {}) { + return render(tree(props)); +} + +/** Sign-out unmounts the button (`hidden`). If the machine stayed `open`, the next ready render would show the menu. */ +function signedOutThenIn(rerender: (ui: React.ReactElement) => void, props: UserButtonProps = {}) { + const signedInUser = user; + const signedInSession = session; + user = null; + session = null; + rerender(tree(props)); + user = signedInUser; + session = signedInSession; + rerender(tree(props)); +} + const host = () => screen.getByTestId('host'); const trigger = () => screen.getByRole('button', { name: /Open account menu/ }); const popup = () => screen.queryByRole('dialog', { name: 'Account' }); @@ -313,7 +329,7 @@ describe('UserButton (connected)', () => { expect(popup()).toBeInTheDocument(); }); - it('signing out of the active account calls signOut with its session id', async () => { + it('signing out of the active account calls signOut with its session id and stays open', async () => { renderUserButton(); const act = await open(); @@ -321,15 +337,33 @@ describe('UserButton (connected)', () => { // Another account stays signed in, so this is a single sign out, not a full one. expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_1', redirectUrl: '/after-single-sign-out' }); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); }); - it('signing out of all accounts calls signOut with the after-sign-out url', async () => { - renderUserButton(); + it('does not reopen after signing out of the last account and signing back in', async () => { + signedInSessions = signedInSessions.slice(0, 1); + const { rerender } = renderUserButton(); const act = await open(); - await act.click(screen.getByRole('button', { name: 'Sign out of all accounts' })); + await accountAction(act, 'Sign out'); + expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_1', redirectUrl: '/after-sign-out' }); + + signedOutThenIn(rerender); + expect(trigger()).toBeInTheDocument(); + expect(popup()).toBeNull(); + }); + it('does not reopen after signing out of all accounts and signing back in', async () => { + const { rerender } = renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Sign out of all accounts' })); expect(signOut).toHaveBeenCalledWith({ redirectUrl: '/after-sign-out' }); + + signedOutThenIn(rerender); + expect(trigger()).toBeInTheDocument(); + expect(popup()).toBeNull(); }); it('accepting an invitation accepts it, revalidates, and stays open', async () => { 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 947de532aed..a4a4a2db2d4 100644 --- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx @@ -193,8 +193,13 @@ export function useUserButtonController( pendingKey: displayPendingKey, onSelectOrganization: runAction(userButtonBusyKeys.selectOrganization, onSelectOrganization, true), onSwitchSession: runAction(userButtonBusyKeys.switchSession, onSwitchSession), - onSignOutSession: runAction(userButtonBusyKeys.signOutSession, onSignOutSession), - onSignOutAll: runAction(userButtonBusyKeys.signOutAll, onSignOutAll), + // Last-account and all-accounts sign-out unmount the button. Staying `open` would reopen the menu on the next sign-in. + onSignOutSession: runAction( + userButtonBusyKeys.signOutSession, + onSignOutSession, + data.additionalSessions.length === 0, + ), + onSignOutAll: runAction(userButtonBusyKeys.signOutAll, onSignOutAll, true), onAcceptSuggestion: runAction(userButtonBusyKeys.acceptSuggestion, onAcceptSuggestion), onAcceptInvitation: runAction(userButtonBusyKeys.acceptInvitation, onAcceptInvitation), onManageAccount: handOff(onManageAccount), From 968b7e7baf01c7f44f5dcdb27408cd34be95c692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 26 Aug 2026 15:37:00 +0200 Subject: [PATCH 61/71] Rename frozen -> frozenModel --- .../__tests__/user-button.integration.test.tsx | 2 +- .../user-button/user-button.controller.tsx | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx index 54e9ce02b6a..403f576ac5f 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx @@ -535,7 +535,7 @@ describe('UserButton (connected)', () => { await waitFor(() => expect(popup()).toBeNull()); }); - // `setActive` swaps the active organization mid-flight. See `frozen` in the machine. + // `setActive` swaps the active organization mid-flight. See `frozenModel` in the machine. it('holds the surface on the data it started with until the action settles', async () => { const deferred = createDeferred(); setActive.mockReturnValueOnce(deferred.promise); 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 a4a4a2db2d4..42dd49cfc4c 100644 --- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx @@ -17,7 +17,7 @@ interface UserButtonMachineContext { * promise is still in flight, so the live model would rearrange the popup mid-action. * The view renders this instead until the action settles. */ - frozen: UserButtonReadyModel | null; + frozenModel: UserButtonReadyModel | null; /** Injected per-action effect — the model callback the clicked row runs. */ run: () => Promise; /** Whether succeeding ends the interaction, and the popup with it. */ @@ -30,21 +30,21 @@ type UserButtonMachineEvent = | { type: 'RUN'; key: string; - frozen: UserButtonReadyModel; + frozenModel: UserButtonReadyModel; run: () => Promise; closeOnSuccess: boolean; }; const { createMachine, assign, fromPromise } = setup(); -const settled = { pendingKey: null, frozen: null }; +const settled = { pendingKey: null, frozenModel: null }; const userButtonMachine = createMachine({ id: 'userButton', initial: 'closed', context: { pendingKey: null, - frozen: null, + frozenModel: null, run: () => Promise.resolve(), closeOnSuccess: false, }, @@ -59,7 +59,7 @@ const userButtonMachine = createMachine({ target: 'busy', actions: assign((_, event) => ({ pendingKey: event.key, - frozen: event.frozen, + frozenModel: event.frozenModel, run: event.run, closeOnSuccess: event.closeOnSuccess, })), @@ -144,7 +144,7 @@ export function useUserButtonController( send({ type: 'RUN', key: keyFor(...args), - frozen: model, + frozenModel: model, run: async () => fn(...args), closeOnSuccess, }) @@ -160,7 +160,7 @@ export function useUserButtonController( : undefined; // Rendering the model the action froze on holds the popup still while it runs; the result - // lands in one step when it settles. See `frozen` in the machine for why. + // lands in one step when it settles. See `frozenModel` in the machine for why. const { status: _status, organizationsEnabled, @@ -176,7 +176,7 @@ export function useUserButtonController( onCreateOrganization, onAddAccount, ...data - } = context.frozen ?? model; + } = context.frozenModel ?? model; // Force user mode if organizations are disabled const mode = model.organizationsEnabled ? requestedMode : 'user'; From 45adf07b70f34b405764a7fd59086d11fb3879da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 27 Aug 2026 10:06:26 +0200 Subject: [PATCH 62/71] Resolve controller status from the frozen model to avoid reverting to fallback --- .../user-button.integration.test.tsx | 34 +++++++++- .../user-button/user-button.controller.tsx | 63 +++++++++++-------- 2 files changed, 68 insertions(+), 29 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx index 403f576ac5f..6965c7cad0e 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx @@ -118,8 +118,8 @@ function list(data: unknown[], count: number, hasNextPage = false, isLoading = f /** A promise whose settling is controlled by the test, to hold an async action in flight. */ function createDeferred() { - let resolve: () => void = () => {}; - let reject: (reason?: unknown) => void = () => {}; + let resolve: () => void = () => { }; + let reject: (reason?: unknown) => void = () => { }; const promise = new Promise((res, rej) => { resolve = res; reject = rej; @@ -559,6 +559,36 @@ describe('UserButton (connected)', () => { await waitFor(() => expect(popup()).toBeNull()); }); + // `setActive` with a navigation puts Clerk in transitive state: hooks report `isLoaded: false` + // and the live model goes `loading`. The frozen model is the one the action started from, so + // the surface has to keep it rather than flashing the fallback + it('does not render the fallback when Clerk resources reverts to loading', async () => { + const deferred = createDeferred(); + setActive.mockReturnValueOnce(deferred.promise); + const props = { fallback: Loading }; + const { rerender } = renderUserButton(props); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Other' })); + await waitFor(() => expect(spinner()).toBeInTheDocument()); + + // Mock the transitive state + isUserLoaded = false; + isSessionLoaded = false; + isOrgLoaded = false; + rerender(tree(props)); + + expect(screen.queryByTestId('fallback')).not.toBeInTheDocument(); + const surface = popup(); + if (!surface) { + throw new Error('expected the popover to be open'); + } + expect(within(surface).getAllByText('Acme')).toHaveLength(2); + + deferred.resolve(); + await waitFor(() => expect(popup()).toBeNull()); + }); + it('spins inside the join button while a suggestion is being joined', async () => { const deferred = createDeferred(); const suggestion = userSuggestions.data[0] as ReturnType; 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 42dd49cfc4c..e8c83a20301 100644 --- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx @@ -28,12 +28,12 @@ type UserButtonMachineEvent = | { type: 'OPEN' } | { type: 'CLOSE' } | { - type: 'RUN'; - key: string; - frozenModel: UserButtonReadyModel; - run: () => Promise; - closeOnSuccess: boolean; - }; + type: 'RUN'; + key: string; + frozenModel: UserButtonReadyModel; + run: () => Promise; + closeOnSuccess: boolean; + }; const { createMachine, assign, fromPromise } = setup(); @@ -114,8 +114,12 @@ export function useUserButtonController( minDuration: context.closeOnSuccess ? 0 : undefined, }); - if (model.status !== 'ready') { - return { status: model.status }; + // If an action is pending and the model is frozen, we use the status of the frozen model. + // This prevents the fallback from showing when we revert from ready->loading during Clerks + // transitive state. + const resolvedModel = context.frozenModel ?? model; + if (resolvedModel.status !== 'ready') { + return { status: resolvedModel.status }; } const close = () => send({ type: 'CLOSE' }); @@ -123,13 +127,13 @@ export function useUserButtonController( const menuItems = customMenuItems?.map(item => item.href === undefined ? { - ...item, - onClick: () => { - // Always close the menu for custom actions - close(); - item.onClick(); - }, - } + ...item, + onClick: () => { + // Always close the menu for custom actions + close(); + item.onClick(); + }, + } : item, ); @@ -141,22 +145,27 @@ export function useUserButtonController( ) => fn ? (...args: Args) => - send({ - type: 'RUN', - key: keyFor(...args), - frozenModel: model, - run: async () => fn(...args), - closeOnSuccess, - }) + send({ + type: 'RUN', + key: keyFor(...args), + // RUN can only happen from a idle state, so this should always + // resolve the actual current model, not a previously frozen + // one. If the logic later changes so RUN can happen outside of + // idle, using the resolvedModel here means we keep using the + // first captured frozen model until all actions settle. + frozenModel: resolvedModel, + run: async () => fn(...args), + closeOnSuccess, + }) : undefined; // A callback that wraps a callback so it always closes the popup when done const handOff = (fn: (() => void) | undefined) => fn ? () => { - close(); - fn(); - } + close(); + fn(); + } : undefined; // Rendering the model the action froze on holds the popup still while it runs; the result @@ -176,10 +185,10 @@ export function useUserButtonController( onCreateOrganization, onAddAccount, ...data - } = context.frozenModel ?? model; + } = resolvedModel; // Force user mode if organizations are disabled - const mode = model.organizationsEnabled ? requestedMode : 'user'; + const mode = organizationsEnabled ? requestedMode : 'user'; return { status: 'ready', From 0fbcd8163e98e32f61521544498ec0c2b18ea4d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 27 Aug 2026 10:07:40 +0200 Subject: [PATCH 63/71] Update fallback jsdoc comment --- packages/ui/src/mosaic/user-button/user-button.tsx | 7 ++++--- 1 file changed, 4 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 60f2094ceb3..84be3e5c0f0 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -30,9 +30,10 @@ export type UserButtonProps = UserButtonModelOptions & UserButtonModeProps & { userProfileProps?: UserButtonUserProfileProps; /** - * Stands in while Clerk is still answering, so the space the button will take is held rather - * than appearing under whatever is beside it. Dropped once nobody is signed in, since that is - * an answer and not a wait. + * Fallback while loading. + * + * Note that the UserButton renders nothing when the user is signed out, so using this on + * pages that are reachable while both signed-out and signed-in can result in Fallback->Nothing. */ fallback?: ReactNode; }; From 13ddc58370cc210c7aa29d07d421502bf792cda9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 27 Aug 2026 10:21:24 +0200 Subject: [PATCH 64/71] Await suggestion/invitation revalidation --- .../user-button.integration.test.tsx | 18 ++++++ .../__tests__/user-button.model.test.tsx | 23 ++++++-- .../mosaic/user-button/user-button.model.tsx | 56 +++++++++++-------- 3 files changed, 68 insertions(+), 29 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx index 6965c7cad0e..9f492f57545 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx @@ -379,6 +379,24 @@ describe('UserButton (connected)', () => { expect(popup()).toBeInTheDocument(); }); + it('stays busy until the invitation lists have revalidated', async () => { + const deferred = createDeferred(); + userInvitations.revalidate.mockReturnValueOnce(deferred.promise); + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Accept' })); + await waitFor(() => expect(userInvitations.revalidate).toHaveBeenCalledTimes(1)); + + // Longer than the spinner's minDuration, so a fire-and-forget refresh would have cleared it. + await new Promise(resolve => setTimeout(resolve, 250)); + expect(spinner()).toBeInTheDocument(); + + deferred.resolve(); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); + }); + it('accepting a suggestion accepts it, revalidates, and stays open', async () => { renderUserButton(); const act = await open(); diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx index c541932e966..295cbc37603 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx @@ -1,7 +1,7 @@ import type * as SharedReact from '@clerk/shared/react'; import { useOrganization } from '@clerk/shared/react'; import type { CustomPage } from '@clerk/shared/types'; -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useOrganizationListInView } from '../../../hooks/useOrganizationListInView'; @@ -56,10 +56,10 @@ let environmentHydrated: boolean; function environment() { return environmentHydrated ? { - displayConfig: { afterSwitchSessionUrl: '/after-switch', branded }, - authConfig: { singleSessionMode }, - organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection }, - } + displayConfig: { afterSwitchSessionUrl: '/after-switch', branded }, + authConfig: { singleSessionMode }, + organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection }, + } : null; } @@ -802,4 +802,17 @@ describe('useUserButtonModel', () => { expect(userSuggestions.revalidate).toHaveBeenCalledTimes(1); expect(userMemberships.revalidate).toHaveBeenCalledTimes(1); }); + + it('does not treat a failed list refresh as a failed accept', async () => { + userInvitations.revalidate.mockRejectedValueOnce(new Error('stale')); + userMemberships.revalidate.mockRejectedValueOnce(new Error('stale')); + userSuggestions.revalidate.mockRejectedValueOnce(new Error('stale')); + const { result } = renderHook(() => useUserButtonModel()); + if (result.current.status !== 'ready') { + throw new Error('expected ready'); + } + + await expect(result.current.onAcceptInvitation?.('inv_1')).resolves.toBeUndefined(); + await expect(result.current.onAcceptSuggestion?.('sug_1')).resolves.toBeUndefined(); + }); }); diff --git a/packages/ui/src/mosaic/user-button/user-button.model.tsx b/packages/ui/src/mosaic/user-button/user-button.model.tsx index 4539add1422..84f926def9f 100644 --- a/packages/ui/src/mosaic/user-button/user-button.model.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.model.tsx @@ -31,13 +31,13 @@ export type UserButtonModel = | { status: 'loading' } | { status: 'hidden' } | (UserButtonData & - Omit & - UserButtonAsyncCallbacks & - UserButtonBrandingProps & { - status: 'ready'; - /** Whether the instance has organizations turned on at all. False forces the button to `user` mode. */ - organizationsEnabled: boolean; - }); + Omit & + UserButtonAsyncCallbacks & + UserButtonBrandingProps & { + status: 'ready'; + /** Whether the instance has organizations turned on at all. False forces the button to `user` mode. */ + organizationsEnabled: boolean; + }); // Mirrors ``: a URL, a `:token` template resolved against the entity, or a builder. type AfterSelectUrl = ((entity: T) => string) | string; @@ -206,15 +206,15 @@ export function useUserButtonModel( 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, - }, - ] + { + kind: 'invitation', + id: i.id, + status: i.status, + organizationId: i.publicOrganizationData.id, + organizationName: i.publicOrganizationData.name, + imageUrl: i.publicOrganizationData.imageUrl || undefined, + }, + ] : [], ); @@ -287,17 +287,25 @@ export function useUserButtonModel( // Covers both restricted instances and users at their creation limit. onCreateOrganization: user.createOrganizationEnabled ? createOrganization : undefined, onAddAccount: singleSessionMode ? undefined : () => void router.navigate(clerk.buildSignInUrl()), - onAcceptSuggestion: suggestionId => { + onAcceptSuggestion: async suggestionId => { const suggestion = suggestionData.find(s => s.id === suggestionId); - return Promise.resolve(suggestion?.accept()).finally(() => void userSuggestions.revalidate?.()); + try { + await suggestion?.accept(); + } finally { + // We always revalidate, a failed accept might be because of old state + // Using allSettled since it never throws and we don't want failed revalidates to look like failed accepts + await Promise.allSettled([userSuggestions.revalidate?.()]); + } }, - // Accepting joins the organization, so memberships are stale too. A suggestion joins nothing. - onAcceptInvitation: invitationId => { + onAcceptInvitation: async invitationId => { const invitation = invitationData.find(i => i.id === invitationId); - return Promise.resolve(invitation?.accept()).finally(() => { - void userInvitations.revalidate?.(); - void userMemberships.revalidate?.(); - }); + try { + await invitation?.accept(); + } finally { + // We always revalidate, a failed accept might be because of old state + // Using allSettled since it never throws and we don't want failed revalidates to look like failed accepts + await Promise.allSettled([userInvitations.revalidate?.(), userMemberships.revalidate?.()]); + } }, }; } From 0fa16feb93a152120b7d5242edec067b8845d420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 27 Aug 2026 10:29:58 +0200 Subject: [PATCH 65/71] Add afterSwitchSessionUrl prop --- .../__tests__/user-button.model.test.tsx | 39 +++++++++++++++++-- .../mosaic/user-button/user-button.model.tsx | 8 +++- .../ui/src/mosaic/user-button/user-button.tsx | 3 +- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx index 295cbc37603..7f4ee1ea062 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx @@ -49,6 +49,7 @@ let singleSessionMode: boolean; let branded: boolean; let forceOrganizationSelection: boolean; let organizationsEnabled: boolean; +let afterSwitchSessionUrl: string; // False stands for the window before clerk-js has hydrated it, which the model has to sit out. let environmentHydrated: boolean; @@ -56,10 +57,10 @@ let environmentHydrated: boolean; function environment() { return environmentHydrated ? { - displayConfig: { afterSwitchSessionUrl: '/after-switch', branded }, - authConfig: { singleSessionMode }, - organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection }, - } + displayConfig: { afterSwitchSessionUrl, branded }, + authConfig: { singleSessionMode }, + organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection }, + } : null; } @@ -155,6 +156,7 @@ beforeEach(() => { branded = true; forceOrganizationSelection = false; organizationsEnabled = true; + afterSwitchSessionUrl = '/after-switch'; environmentHydrated = true; signedInSessions = [ { id: 'sess_1', user: user }, @@ -623,6 +625,35 @@ describe('useUserButtonModel', () => { expect(decorateUrl).toHaveBeenCalledWith('/after-switch'); }); + it('does not navigate after a session switch when no after-switch URL is set', async () => { + afterSwitchSessionUrl = ''; + render(); + fireEvent.click(screen.getByText('switch')); + + const navigateOnSetActive = setActive.mock.calls[0][0].navigate; + const decorateUrl = vi.fn((url: string) => url); + await act(async () => { + await navigateOnSetActive({ session: { currentTask: null }, decorateUrl }); + }); + + expect(navigate).not.toHaveBeenCalled(); + expect(decorateUrl).not.toHaveBeenCalled(); + }); + + it('prefers the afterSwitchSessionUrl prop over the instance URL', async () => { + render(); + fireEvent.click(screen.getByText('switch')); + + const navigateOnSetActive = setActive.mock.calls[0][0].navigate; + const decorateUrl = vi.fn((url: string) => url); + await act(async () => { + await navigateOnSetActive({ session: { currentTask: null }, decorateUrl }); + }); + + expect(navigate).toHaveBeenCalledWith('/app-switch'); + expect(decorateUrl).toHaveBeenCalledWith('/app-switch'); + }); + // An instance can restrict who may open an organization, and a user at their creation limit is // restricted the same way. Offering the action anyway lands them on a page that turns them away. it('drops create-organization for a user who cannot open one', () => { diff --git a/packages/ui/src/mosaic/user-button/user-button.model.tsx b/packages/ui/src/mosaic/user-button/user-button.model.tsx index 84f926def9f..704c5b5df51 100644 --- a/packages/ui/src/mosaic/user-button/user-button.model.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.model.tsx @@ -61,6 +61,8 @@ export type UserButtonModelOptions = UserProfileMode & afterSelectOrganizationUrl?: AfterSelectUrl; /** Where selecting the personal workspace lands. Resolved against the user, not an organization. */ afterSelectPersonalUrl?: AfterSelectUrl; + /** Where switching account lands. The instance URL is used when this is omitted. */ + afterSwitchSessionUrl?: string; /** * Leaves the personal workspace out. An instance that forces organization selection withholds it * either way, so this cannot opt back in. @@ -267,8 +269,12 @@ export function useUserButtonModel( await router.navigate(buildTaskUrl(task, { base: clerk.buildSignInUrl() })); return; } + const afterSwitchSessionUrl = options?.afterSwitchSessionUrl || displayConfig.afterSwitchSessionUrl; + if (!afterSwitchSessionUrl) { + return; + } // `redirectUrl` decorated for us; taking the callback takes the Safari ITP refresh with it. - await router.navigate(decorateUrl(displayConfig.afterSwitchSessionUrl)); + await router.navigate(decorateUrl(afterSwitchSessionUrl)); }, }), onSignOutSession: sessionId => diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx index 84be3e5c0f0..324a42b0947 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -65,12 +65,13 @@ export type UserButtonProps = UserButtonModelOptions & * @example * Passing a URL routes to a page of your own instead of opening Clerk's modal; that is the whole * opt-in. `afterSelectOrganizationUrl` is where switching organization lands, and takes a `:param` - * template, a plain path, or a function. + * template, a plain path, or a function. `afterSwitchSessionUrl` is where switching account lands. * ```tsx * * ``` * From 647627368bf17fc51f967a9d66803fb342a17f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 27 Aug 2026 11:03:21 +0200 Subject: [PATCH 66/71] Handle "No organization selected" --- packages/swingset/src/stories/user-button.mdx | 14 +++- .../src/stories/user-button.stories.tsx | 30 ++++++++- .../user-button.integration.test.tsx | 21 +++++- .../__tests__/user-button.view.test.tsx | 25 +++++++ .../user-button/user-button.messages.ts | 1 + .../mosaic/user-button/user-button.types.ts | 3 +- .../mosaic/user-button/user-button.view.tsx | 66 ++++++++++++++----- 7 files changed, 137 insertions(+), 23 deletions(-) diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx index a4068ee6b14..f32bbdc1671 100644 --- a/packages/swingset/src/stories/user-button.mdx +++ b/packages/swingset/src/stories/user-button.mdx @@ -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. @@ -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**. + + + ## Menu items `customMenuItems` adds the app's own rows to the foot of the popup, ahead of Clerk's own. A row with diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx index 7997b90e03e..ef732a934d5 100644 --- a/packages/swingset/src/stories/user-button.stories.tsx +++ b/packages/swingset/src/stories/user-button.stories.tsx @@ -143,9 +143,21 @@ 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 { +function usePrototype({ + hidePersonal = false, + startWithoutOrganization = false, +}: { + hidePersonal?: boolean; + startWithoutOrganization?: boolean; +} = {}): Omit { 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(null); @@ -196,6 +208,7 @@ function usePrototype(): Omit { 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 => @@ -300,6 +313,19 @@ export function User(_args: Record) { ); } +export function NoOrganizationSelected(_args: Record) { + 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 ( + + ); +} + export function SingleSession(_args: Record) { const prototype = usePrototype(); diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx index 9f492f57545..ee11f79cf03 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx @@ -51,6 +51,7 @@ let signedInSessions: FakeSession[]; let pagingRef: ReturnType; let singleSessionMode: boolean; let organizationsEnabled: boolean; +let forceOrganizationSelection: boolean; let setActive: ReturnType; let signOut: ReturnType; @@ -85,7 +86,7 @@ vi.mock('@clerk/shared/react', async importOriginal => { __internal_environment: { displayConfig: { afterSwitchSessionUrl: '/after-switch' }, authConfig: { singleSessionMode }, - organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection: false }, + organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection }, commerceSettings: { billing: { user: { enabled: false } } }, apiKeysSettings: { user_api_keys_enabled: false }, }, @@ -149,6 +150,7 @@ beforeEach(() => { pagingRef = vi.fn(); singleSessionMode = false; organizationsEnabled = true; + forceOrganizationSelection = false; signedInSessions = [ { id: 'sess_1', user }, { @@ -308,6 +310,23 @@ describe('UserButton (connected)', () => { expect(screen.getByRole('button', { name: 'Other' })).toBeInTheDocument(); }); + it('names no organization selected when the instance forces one and none is active', async () => { + forceOrganizationSelection = true; + organization = null; + renderUserButton(); + + expect(screen.getByRole('button', { name: /No organization selected/ })).toBeInTheDocument(); + await open(); + + const surface = popup(); + if (!surface) { + throw new Error('expected the popover to be open'); + } + expect(within(surface).getByText('No organization selected')).toBeInTheDocument(); + expect(screen.queryByText('Personal account')).toBeNull(); + expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument(); + }); + // `mode` is the view's own prop; this only proves the connected component hands it down, since // the account-only surface is otherwise indistinguishable from an account with no organizations. it('forwards mode to the view, so an account-only surface lists no organizations', async () => { diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx index fa480affdf0..3d6ffbb851c 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx @@ -199,6 +199,17 @@ describe('UserButtonView, organization mode', () => { expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); }); + // `hidePersonal` withholds the workspace, so a missing org is no selection — not the account. + it('names no organization selected where personal is hidden and none is active', () => { + renderOrganizationMode({ hidePersonal: true, activeOrganization: null }); + + expect(within(groups()[0]).getByText('No organization selected')).toBeInTheDocument(); + expect(screen.queryByText('Alice Smith')).toBeNull(); + expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Manage organization' })).toBeNull(); + }); + // The header acts on the active organization, which is known whole before the list it belongs to // lands. Invite and the gear act on the same organization, so they answer together. it('offers to invite while the membership list is still in flight', () => { @@ -811,6 +822,20 @@ describe('UserButtonTrigger', () => { expect(screen.queryByText('Pro')).toBeNull(); }); + it('names no organization selected where personal is hidden and none is active', () => { + renderTrigger({ mode: 'organization', hidePersonal: true, activeOrganization: null }); + + expect(screen.getByText('No organization selected')).toBeInTheDocument(); + expect(screen.queryByText('Alice Smith')).toBeNull(); + }); + + it('still names the account in user mode when personal is hidden and none is active', () => { + renderTrigger({ mode: 'user', hidePersonal: true, activeOrganization: null }); + + expect(screen.getByText('Alice Smith')).toBeInTheDocument(); + expect(screen.queryByText('No organization selected')).toBeNull(); + }); + it('names the active organization in combined mode', () => { renderTrigger({ mode: 'combined' }); diff --git a/packages/ui/src/mosaic/user-button/user-button.messages.ts b/packages/ui/src/mosaic/user-button/user-button.messages.ts index 43a4e25e4e5..812ddf8f961 100644 --- a/packages/ui/src/mosaic/user-button/user-button.messages.ts +++ b/packages/ui/src/mosaic/user-button/user-button.messages.ts @@ -15,6 +15,7 @@ export const userButtonBase = { }, workspaces: { personal: 'Personal account', + notSelected: 'No organization selected', loading: 'Loading organizations…', members: { one: '{count} member', other: '{count} members' }, accept: 'Accept', diff --git a/packages/ui/src/mosaic/user-button/user-button.types.ts b/packages/ui/src/mosaic/user-button/user-button.types.ts index a8a89242481..e7a69fbf3b6 100644 --- a/packages/ui/src/mosaic/user-button/user-button.types.ts +++ b/packages/ui/src/mosaic/user-button/user-button.types.ts @@ -51,7 +51,8 @@ export interface UserButtonData { activeSession: UserButtonSession; /** * The active organization, described whole rather than found in `memberships`, so the surface - * names it while the list it belongs to is still loading. `null` => the personal workspace. + * names it while the list it belongs to is still loading. `null` means none is active: the + * personal workspace when one exists, and no selection otherwise. */ activeOrganization: UserButtonMembership | null; /** diff --git a/packages/ui/src/mosaic/user-button/user-button.view.tsx b/packages/ui/src/mosaic/user-button/user-button.view.tsx index 9568e439b2d..ada9c3a745f 100644 --- a/packages/ui/src/mosaic/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -86,23 +86,42 @@ function useBusy(key?: string): { busy: boolean; disabled: boolean } { return { busy: pendingKey === key, disabled: pendingKey !== key }; } -interface ActiveWorkspace { - name: string; - imageUrl?: string; - shape: 'circle' | 'square'; - /** Absent when the personal account is what's active. */ - organization?: UserButtonMembership; -} +type ActiveWorkspace = + | { + kind: 'organization'; + name: string; + imageUrl?: string; + shape: 'square'; + organization: UserButtonMembership; + } + | { kind: 'user'; name: string; imageUrl?: string; shape: 'circle' } + | { kind: 'none'; name: string; imageUrl?: string; shape: 'square' }; /** * What the surface leads with: named in the trigger and headed in the popup, so the two always - * agree. Only an organization-led surface with an organization actually active resolves to one. + * agree. An organization-led surface with no org and no personal workspace is no selection. */ -function leadWorkspace({ layout, activeOrganization, activeSession }: UserButtonContextValue): ActiveWorkspace { - const organization = layout.leadWith === 'organization' ? activeOrganization : null; - return organization - ? { name: organization.name, imageUrl: organization.imageUrl, shape: 'square', organization } - : { name: activeSession.name, imageUrl: activeSession.imageUrl, shape: 'circle' }; +function leadWorkspace({ + layout, + activeOrganization, + activeSession, + hidePersonal, +}: UserButtonContextValue): ActiveWorkspace { + if (layout.leadWith === 'organization') { + if (activeOrganization) { + return { + kind: 'organization', + name: activeOrganization.name, + imageUrl: activeOrganization.imageUrl, + shape: 'square', + organization: activeOrganization, + }; + } + if (hidePersonal) { + return { kind: 'none', name: m.workspaces.notSelected, shape: 'square' }; + } + } + return { kind: 'user', name: activeSession.name, imageUrl: activeSession.imageUrl, shape: 'circle' }; } function membershipSubtitle(membership: UserButtonMembership): string { @@ -351,10 +370,18 @@ function Header() { const data = useUserButtonContext(); const signOutSession = data.onSignOutSession; const { sessionId, identifier } = data.activeSession; - const { name, imageUrl, shape, organization } = leadWorkspace(data); + const workspace = leadWorkspace(data); + const { name, imageUrl, shape } = workspace; + const organization = workspace.kind === 'organization' ? workspace.organization : undefined; // An account with no name is titled by its identifier, and repeating it underneath says nothing. + // No selection is not the account, so it carries no identifier line either. const accountSubtitle = identifier === name ? '' : identifier; - const subtitle = organization ? membershipSubtitle(organization) : accountSubtitle; + const subtitle = + workspace.kind === 'organization' + ? membershipSubtitle(workspace.organization) + : workspace.kind === 'user' + ? accountSubtitle + : ''; const actions: HeaderAction[] = []; for (const action of data.layout.actions.header) { @@ -1026,7 +1053,8 @@ export function UserButtonRoot(props: UserButtonRootProps): ReactElement { export interface UserButtonTriggerProps { /** * Names the active workspace beside its avatar — the organization wherever one heads the - * trigger, the account otherwise. Turn it off for the avatar alone. + * trigger, no selection when personal is hidden and none is active, the account otherwise. + * Turn it off for the avatar alone. * * @default true */ @@ -1046,8 +1074,10 @@ export function UserButtonTrigger({ renderTriggerBadge = true, }: UserButtonTriggerProps = {}): ReactElement { const data = useUserButtonContext(); - const { name, imageUrl, shape, organization } = leadWorkspace(data); - const planLabel = renderTriggerBadge ? organization?.planLabel : undefined; + const workspace = leadWorkspace(data); + const { name, imageUrl, shape } = workspace; + const planLabel = + renderTriggerBadge && workspace.kind === 'organization' ? workspace.organization.planLabel : undefined; return ( Date: Thu, 27 Aug 2026 11:12:27 +0200 Subject: [PATCH 67/71] Add comment with possibly missing props compared to old UserButton --- packages/ui/src/mosaic/user-button/user-button.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx index 324a42b0947..4abdcfc67cc 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -24,6 +24,8 @@ export interface UserButtonUserProfileProps { } /** Everything `` takes: profile routing, trigger content, the app's own menu rows, and the profile it opens. */ +// TODO: Possibly missing, verify these before GA: +// defaultOpen, signInUrl, userProfileProps.additionalOAuthScopes, userProfileProps.apiKeysProps, userProfileProps.appearance, customMenuItems open/startPath, afterCreateOrganizationUrl, skipInvitationScreen, afterLeaveOrganizationUrl, organizationProfileProps export type UserButtonProps = UserButtonModelOptions & UserButtonTriggerProps & UserButtonMenuProps & From dd14d63cb77819b165d0f8eec60ce5c796b948a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 27 Aug 2026 11:12:53 +0200 Subject: [PATCH 68/71] Fix swingset formatting --- packages/swingset/src/stories/user-button.stories.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx index ef732a934d5..3bb74cb952e 100644 --- a/packages/swingset/src/stories/user-button.stories.tsx +++ b/packages/swingset/src/stories/user-button.stories.tsx @@ -153,9 +153,7 @@ function usePrototype({ const [open, setOpen] = useState(false); const [accounts, setAccounts] = useState(() => startWithoutOrganization - ? initialAccounts.map(a => - a.session.sessionId === colin.sessionId ? { ...a, activeOrganizationId: null } : a, - ) + ? initialAccounts.map(a => (a.session.sessionId === colin.sessionId ? { ...a, activeOrganizationId: null } : a)) : initialAccounts, ); const [activeSessionId, setActiveSessionId] = useState(colin.sessionId); From c20f0dc7ae5390d2bf2b0610e5c1db9e0f6763ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 27 Aug 2026 12:03:26 +0200 Subject: [PATCH 69/71] Refactor open/closed state to be a machine context value instead of a state --- .../__tests__/user-button.controller.test.tsx | 2 +- .../user-button.integration.test.tsx | 50 +++++++++++++++++++ .../user-button/user-button.controller.tsx | 43 ++++++++-------- 3 files changed, 74 insertions(+), 21 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 fe94b7bf2dc..726b6ccb56c 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 @@ -235,7 +235,7 @@ describe('useUserButtonController', () => { expect(screen.getByTestId('open')).toHaveTextContent('false'); }); - it('abandons an action dismissed mid-flight rather than reopening on its result', async () => { + it('stays closed when a dismissed action settles', async () => { const pending = deferred(); const onSwitchSession = vi.fn(() => pending.promise); render(); diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx index ee11f79cf03..3532d10ee37 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx @@ -660,6 +660,56 @@ describe('UserButton (connected)', () => { expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeEnabled(); }); + // Closing must not drop the invoke: reopen should find the same row still pending, and a second + // action must not start. Wait out the spinner hold so a stale minDuration cannot fake this. + it('still shows the in-flight action when the popover is reopened before it settles', async () => { + const deferred = createDeferred(); + setActive.mockReturnValueOnce(deferred.promise); + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'bob@example.com' })); + expect(spinner()).toBeInTheDocument(); + + await act.click(trigger()); + expect(popup()).toBeNull(); + + await new Promise(resolve => setTimeout(resolve, 250)); + await act.click(trigger()); + + const bob = screen.getByRole('button', { name: 'bob@example.com' }); + expect(popup()).toBeInTheDocument(); + expect(bob).toHaveAttribute('aria-busy', 'true'); + expect(spinner()).toBeInTheDocument(); + + await act.click(screen.getByRole('button', { name: 'Other' })); + expect(setActive).toHaveBeenCalledTimes(1); + + deferred.resolve(); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); + }); + + it('closes on success even if the popover was dismissed and reopened while the action ran', async () => { + const deferred = createDeferred(); + setActive.mockReturnValueOnce(deferred.promise); + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Other' })); + expect(spinner()).toBeInTheDocument(); + + await act.click(trigger()); + expect(popup()).toBeNull(); + + await act.click(trigger()); + expect(popup()).toBeInTheDocument(); + expect(spinner()).toBeInTheDocument(); + + deferred.resolve(); + await waitFor(() => expect(popup()).toBeNull()); + }); + // The spinner is held up for a minimum so it cannot flicker off. That hold is for a surface still // on screen, so an action that closes the surface must not carry it: reopening inside the window // would otherwise find the popup spinning over rows that are all stood down, for nothing. 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 e8c83a20301..d72135bb834 100644 --- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx @@ -10,6 +10,8 @@ import { userButtonBusyKeys } from './user-button.view'; export type UserButtonReadyModel = Extract; interface UserButtonMachineContext { + /** Independent of the action: dismissing must not abandon an in-flight invoke. */ + open: boolean; /** Which action is currently pending. */ pendingKey: string | null; /** @@ -41,22 +43,22 @@ const settled = { pendingKey: null, frozenModel: null }; const userButtonMachine = createMachine({ id: 'userButton', - initial: 'closed', + initial: 'idle', context: { + open: false, pendingKey: null, frozenModel: null, run: () => Promise.resolve(), closeOnSuccess: false, }, states: { - closed: { - on: { OPEN: 'open' }, - }, - open: { + idle: { on: { - CLOSE: 'closed', + OPEN: { actions: assign(() => ({ open: true })) }, + CLOSE: { actions: assign(() => ({ open: false })) }, RUN: { target: 'busy', + guard: context => context.open, actions: assign((_, event) => ({ pendingKey: event.key, frozenModel: event.frozenModel, @@ -66,20 +68,23 @@ const userButtonMachine = createMachine({ }, }, }, - // Reached only from `open`, so a busy popup that is not open is unrepresentable, and RUN going - // unhandled here is what stops a second action starting while one is in flight. Dismissing the - // popup abandons the action: the request finishes, but nothing is left for its result to land in. + // OPEN/CLOSE have no target so they do not leave this state and abandon the invoke. busy: { - on: { CLOSE: { target: 'closed', actions: assign(() => settled) } }, + on: { + OPEN: { actions: assign(() => ({ open: true })) }, + CLOSE: { actions: assign(() => ({ open: false })) }, + }, invoke: fromPromise(context => context.run(), { onDone: [ - { target: 'closed', guard: context => context.closeOnSuccess, actions: assign(() => settled) }, - { target: 'open', actions: assign(() => settled) }, + { + target: 'idle', + guard: context => context.closeOnSuccess, + actions: assign(() => ({ ...settled, open: false })), + }, + { target: 'idle', actions: assign(() => settled) }, ], - // The popup stays up on a failure so the row can be clicked again. Nothing reports what went - // wrong yet; the error surface is its own change, and carrying a message before one exists - // would mean shipping an untranslated string nobody reads. - onError: { target: 'open', actions: assign(() => settled) }, + // Leave `open` as the user left it. The error surface is a later change. + onError: { target: 'idle', actions: assign(() => settled) }, }), }, }, @@ -103,9 +108,7 @@ export function useUserButtonController( options: UserButtonControllerOptions = {}, ): UserButtonController { const { mode: requestedMode, modePriority, customMenuItems, menuItemOrder } = options; - // The popover's open state and the one action in flight are the same flow: an action that ends the - // interaction closes the surface, so they settle together or not at all. - const [{ value, context }, send] = useMachine(userButtonMachine); + const [{ context }, send] = useMachine(userButtonMachine); // Every action here is a network round trip, so we can start the // pending state immediately, we use this for the minDuration @@ -197,7 +200,7 @@ export function useUserButtonController( modePriority, customMenuItems: menuItems, menuItemOrder, - open: value !== 'closed', + open: context.open, onOpenChange: next => send(next ? { type: 'OPEN' } : { type: 'CLOSE' }), pendingKey: displayPendingKey, onSelectOrganization: runAction(userButtonBusyKeys.selectOrganization, onSelectOrganization, true), From f4e55a3b9612824810d3843e6a48a528e74fe1ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 27 Aug 2026 12:08:02 +0200 Subject: [PATCH 70/71] Format --- .../user-button.integration.test.tsx | 4 +- .../user-button/user-button.controller.tsx | 56 +++++++++---------- .../mosaic/user-button/user-button.model.tsx | 32 +++++------ 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx index 3532d10ee37..8f91f5aacf1 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx @@ -119,8 +119,8 @@ function list(data: unknown[], count: number, hasNextPage = false, isLoading = f /** A promise whose settling is controlled by the test, to hold an async action in flight. */ function createDeferred() { - let resolve: () => void = () => { }; - let reject: (reason?: unknown) => void = () => { }; + let resolve: () => void = () => {}; + let reject: (reason?: unknown) => void = () => {}; const promise = new Promise((res, rej) => { resolve = res; reject = rej; 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 d72135bb834..e91242cc9ff 100644 --- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx @@ -30,12 +30,12 @@ type UserButtonMachineEvent = | { type: 'OPEN' } | { type: 'CLOSE' } | { - type: 'RUN'; - key: string; - frozenModel: UserButtonReadyModel; - run: () => Promise; - closeOnSuccess: boolean; - }; + type: 'RUN'; + key: string; + frozenModel: UserButtonReadyModel; + run: () => Promise; + closeOnSuccess: boolean; + }; const { createMachine, assign, fromPromise } = setup(); @@ -130,13 +130,13 @@ export function useUserButtonController( const menuItems = customMenuItems?.map(item => item.href === undefined ? { - ...item, - onClick: () => { - // Always close the menu for custom actions - close(); - item.onClick(); - }, - } + ...item, + onClick: () => { + // Always close the menu for custom actions + close(); + item.onClick(); + }, + } : item, ); @@ -148,27 +148,27 @@ export function useUserButtonController( ) => fn ? (...args: Args) => - send({ - type: 'RUN', - key: keyFor(...args), - // RUN can only happen from a idle state, so this should always - // resolve the actual current model, not a previously frozen - // one. If the logic later changes so RUN can happen outside of - // idle, using the resolvedModel here means we keep using the - // first captured frozen model until all actions settle. - frozenModel: resolvedModel, - run: async () => fn(...args), - closeOnSuccess, - }) + send({ + type: 'RUN', + key: keyFor(...args), + // RUN can only happen from a idle state, so this should always + // resolve the actual current model, not a previously frozen + // one. If the logic later changes so RUN can happen outside of + // idle, using the resolvedModel here means we keep using the + // first captured frozen model until all actions settle. + frozenModel: resolvedModel, + run: async () => fn(...args), + closeOnSuccess, + }) : undefined; // A callback that wraps a callback so it always closes the popup when done const handOff = (fn: (() => void) | undefined) => fn ? () => { - close(); - fn(); - } + close(); + fn(); + } : undefined; // Rendering the model the action froze on holds the popup still while it runs; the result diff --git a/packages/ui/src/mosaic/user-button/user-button.model.tsx b/packages/ui/src/mosaic/user-button/user-button.model.tsx index 704c5b5df51..20404039508 100644 --- a/packages/ui/src/mosaic/user-button/user-button.model.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.model.tsx @@ -31,13 +31,13 @@ export type UserButtonModel = | { status: 'loading' } | { status: 'hidden' } | (UserButtonData & - Omit & - UserButtonAsyncCallbacks & - UserButtonBrandingProps & { - status: 'ready'; - /** Whether the instance has organizations turned on at all. False forces the button to `user` mode. */ - organizationsEnabled: boolean; - }); + Omit & + UserButtonAsyncCallbacks & + UserButtonBrandingProps & { + status: 'ready'; + /** Whether the instance has organizations turned on at all. False forces the button to `user` mode. */ + organizationsEnabled: boolean; + }); // Mirrors ``: a URL, a `:token` template resolved against the entity, or a builder. type AfterSelectUrl = ((entity: T) => string) | string; @@ -208,15 +208,15 @@ export function useUserButtonModel( 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, - }, - ] + { + kind: 'invitation', + id: i.id, + status: i.status, + organizationId: i.publicOrganizationData.id, + organizationName: i.publicOrganizationData.name, + imageUrl: i.publicOrganizationData.imageUrl || undefined, + }, + ] : [], ); From 7e5061c27afe3bbc0de57482add5beca636f9851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 27 Aug 2026 17:08:44 +0200 Subject: [PATCH 71/71] Fix tests --- .../user-button.integration.test.tsx | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx index 8f91f5aacf1..e9b14ca5463 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx @@ -228,6 +228,12 @@ async function accountAction(act: ReturnType, label: str await act.click(await screen.findByRole('menuitem', { name: label })); } +/** Opens the accounts flyout at the foot, and hands back the menu it opens. */ +async function openAccounts(act: ReturnType) { + await act.click(screen.getByRole('button', { name: 'Switch account' })); + return screen.findByRole('menu'); +} + describe('UserButton (connected)', () => { // Nothing stands in for the button before Clerk answers, in any mode: until it does, a signed-out // visitor is indistinguishable from a session still resolving, so a placeholder here would be @@ -254,7 +260,7 @@ describe('UserButton (connected)', () => { // The account heads the surface, rather than the organization that is active regardless. expect(screen.getByRole('button', { name: 'Open account menu for Alice Smith' })).toBeInTheDocument(); - await open(); + const act = await open(); for (const name of ['Acme', 'Other', 'Beta', 'Gamma', 'Personal account']) { expect(screen.queryByText(name)).toBeNull(); @@ -264,7 +270,8 @@ describe('UserButton (connected)', () => { // Everything the account itself carries is still on offer. expect(screen.getByRole('button', { name: 'Sign out' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'bob@example.com' })).toBeInTheDocument(); + const menu = await openAccounts(act); + expect(within(menu).getByRole('menuitem', { name: 'bob@example.com' })).toBeInTheDocument(); }); }); @@ -331,17 +338,19 @@ describe('UserButton (connected)', () => { // the account-only surface is otherwise indistinguishable from an account with no organizations. it('forwards mode to the view, so an account-only surface lists no organizations', async () => { renderUserButton({ mode: 'user' }); - await open(); + const act = await open(); expect(screen.queryByRole('button', { name: 'Other' })).toBeNull(); - expect(screen.getByRole('button', { name: 'bob@example.com' })).toBeInTheDocument(); + const menu = await openAccounts(act); + expect(within(menu).getByRole('menuitem', { name: 'bob@example.com' })).toBeInTheDocument(); }); it('switching to another account calls setActive with the session and stays open', async () => { renderUserButton(); const act = await open(); - await act.click(screen.getByRole('button', { name: 'bob@example.com' })); + const menu = await openAccounts(act); + await act.click(within(menu).getByRole('menuitem', { name: 'bob@example.com' })); expect(setActive).toHaveBeenCalledWith({ session: 'sess_2', navigate: expect.any(Function) }); await waitFor(() => expect(spinner()).toBeNull()); @@ -565,7 +574,7 @@ describe('UserButton (connected)', () => { // keeps its place in the tab order. Dropping it to a static row would remount it, and with it // the avatar it carries. expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toHaveAttribute('aria-disabled', 'true'); - expect(screen.getByRole('button', { name: 'bob@example.com' })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: 'Switch account' })).toBeDisabled(); expect(popup()).toBeInTheDocument(); deferred.resolve(); @@ -668,7 +677,8 @@ describe('UserButton (connected)', () => { renderUserButton(); const act = await open(); - await act.click(screen.getByRole('button', { name: 'bob@example.com' })); + const menu = await openAccounts(act); + await act.click(within(menu).getByRole('menuitem', { name: 'bob@example.com' })); expect(spinner()).toBeInTheDocument(); await act.click(trigger()); @@ -677,9 +687,9 @@ describe('UserButton (connected)', () => { await new Promise(resolve => setTimeout(resolve, 250)); await act.click(trigger()); - const bob = screen.getByRole('button', { name: 'bob@example.com' }); + const switchAccount = screen.getByRole('button', { name: 'Switch account' }); expect(popup()).toBeInTheDocument(); - expect(bob).toHaveAttribute('aria-busy', 'true'); + expect(switchAccount.querySelector('.cl-spinner')).not.toBeNull(); expect(spinner()).toBeInTheDocument(); await act.click(screen.getByRole('button', { name: 'Other' }));