From 5c977abb20f913ad77938f4efd681e1c6bf94bed Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 11:38:12 -0400 Subject: [PATCH 01/31] test(ui): cover the connected UserButton end to end Render the connected UserButton against stubbed Clerk hooks and drive it through the popover: organization selection, account switching, sign out, accepting invitations and suggestions, single-session mode, navigation actions, busy and loading states, and the paging sentinel. --- .../user-button.integration.test.tsx | 424 ++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx 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 new file mode 100644 index 00000000000..e02cd311732 --- /dev/null +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx @@ -0,0 +1,424 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +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 the container's +// close-on-success: one-shot actions close the popover, navigations leave it open. + +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: ReturnType; +let singleSessionMode: boolean; + +let setActive: ReturnType; +let signOut: ReturnType; +let navigate: 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 }, + }, + }), + }; +}); + +// Stubbed at the same seam as the controller test: the in-view helper is the controller's whole +// fetch boundary, so `ref` doubles as the assertion that the paging sentinel mounted. +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) }; +} + +/** 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 = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +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: 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 }, + { + 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 renderUserButton(props: UserButtonProps = {}) { + return render( + + {/* The button portals its popup out, so this host holds only what it renders in place. */} +
+ +
+
, + ); +} + +const host = () => screen.getByTestId('host'); +const trigger = () => screen.getByRole('button', { name: /Open account menu/ }); +const popup = () => screen.queryByRole('dialog', { name: 'Account' }); +const spinner = () => popup()?.querySelector('[data-cl-spinner]') ?? null; + +async function open() { + const act = userEvent.setup(); + await act.click(trigger()); + expect(popup()).toBeInTheDocument(); + return act; +} + +// Queried by label, not role: floating-ui gives any menu trigger inside another floating element +// `role="menuitem"`, so the `⋯` in the popover is not reachable as a button. +const accountMenu = () => screen.getByLabelText('Actions for alice@example.com'); + +/** Opens the `⋯` on the active account's row and clicks one of its actions. */ +async function accountAction(act: ReturnType, label: string) { + await act.click(accountMenu()); + await act.click(await screen.findByRole('menuitem', { name: label })); +} + +describe('UserButton (connected)', () => { + it('renders a non-interactive placeholder while the controller is loading', () => { + isUserLoaded = false; + renderUserButton(); + + expect(host()).not.toBeEmptyDOMElement(); + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('renders nothing when there is no active user', () => { + user = null; + renderUserButton(); + expect(host()).toBeEmptyDOMElement(); + }); + + it('renders the trigger and keeps the popover closed until clicked', () => { + renderUserButton(); + expect(trigger()).toBeInTheDocument(); + expect(popup()).toBeNull(); + }); + + it('opens the popover on trigger click', async () => { + renderUserButton(); + await open(); + + expect(screen.getByRole('button', { name: 'Other' })).toBeInTheDocument(); + expect(accountMenu()).toBeInTheDocument(); + }); + + it('does not offer the active organization as something to select', async () => { + renderUserButton(); + await open(); + + expect(screen.queryByRole('button', { name: 'Acme' })).toBeNull(); + expect(screen.getByText('Acme')).toBeInTheDocument(); + }); + + it('selecting an organization calls setActive without a redirect by default and closes the popover', async () => { + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Other' })); + + expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined }); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('selecting an organization redirects to the configured afterSelectOrganizationUrl', async () => { + const act = userEvent.setup(); + renderUserButton({ afterSelectOrganizationUrl: org => `/o/${org.id}` }); + await act.click(trigger()); + await act.click(screen.getByRole('button', { name: 'Other' })); + + expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/o/org_9' }); + }); + + it('switching to another account calls setActive with the session and closes', async () => { + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'bob@example.com' })); + + expect(setActive).toHaveBeenCalledWith({ session: 'sess_2', redirectUrl: '/after-switch' }); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('signing out of the active account calls signOut with its session id and closes', async () => { + renderUserButton(); + const act = await open(); + + await accountAction(act, 'Sign out'); + + // 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(popup()).toBeNull()); + }); + + it('signing out of all accounts calls signOut with the after-sign-out url and closes', async () => { + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Sign out of all accounts' })); + + expect(signOut).toHaveBeenCalledWith({ redirectUrl: '/after-sign-out' }); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('accepting an invitation accepts it, revalidates, and closes', async () => { + renderUserButton(); + const act = await open(); + const invitation = userInvitations.data[0] as ReturnType; + + await act.click(screen.getByRole('button', { name: 'Accept' })); + + await waitFor(() => expect(invitation.accept).toHaveBeenCalledTimes(1)); + expect(userInvitations.revalidate).toHaveBeenCalledTimes(1); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('accepting a suggestion accepts it, revalidates, and closes', async () => { + renderUserButton(); + const act = await open(); + const suggestion = userSuggestions.data[0] as ReturnType; + + await act.click(screen.getByRole('button', { name: 'Join' })); + + await waitFor(() => expect(suggestion.accept).toHaveBeenCalledTimes(1)); + expect(userSuggestions.revalidate).toHaveBeenCalledTimes(1); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('reports an already-accepted suggestion instead of offering to join it again', async () => { + userSuggestions = list([acceptable('sug_1', 'org_2', 'Beta', 'accepted')], 1); + renderUserButton(); + await open(); + + expect(screen.queryByRole('button', { name: 'Join' })).toBeNull(); + expect(screen.getByText('Requested')).toBeInTheDocument(); + }); + + it('lists pending invitations and suggestions even with no organization memberships', async () => { + userMemberships = list([], 0); + organization = null; + renderUserButton(); + await open(); + + expect(screen.getByRole('button', { name: 'Accept' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Join' })).toBeInTheDocument(); + }); + + it('drops add-account and sign-out-of-all in single-session mode', async () => { + singleSessionMode = true; + signedInSessions = signedInSessions.slice(0, 1); + renderUserButton(); + const act = await open(); + + expect(screen.queryByRole('button', { name: 'Sign out of all accounts' })).toBeNull(); + expect(screen.queryByLabelText('Account actions')).toBeNull(); + await act.click(accountMenu()); + expect(screen.queryByRole('menuitem', { name: 'Add account' })).toBeNull(); + }); + + it('managing the account navigates and leaves the popover open', async () => { + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Manage account' })); + + expect(navigate).toHaveBeenCalledWith('/user-profile'); + expect(popup()).toBeInTheDocument(); + }); + + it('inviting members navigates and leaves the popover open', async () => { + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Invite' })); + + expect(navigate).toHaveBeenCalledWith('/org-profile'); + expect(popup()).toBeInTheDocument(); + }); + + it('creating an organization navigates and leaves the popover open', async () => { + renderUserButton(); + const act = await open(); + + await accountAction(act, 'Create organization'); + + expect(navigate).toHaveBeenCalledWith('/create-org'); + expect(popup()).toBeInTheDocument(); + }); + + it('spins the clicked affordance and stands every other one down while an action is in flight', async () => { + const deferred = createDeferred(); + setActive.mockReturnValueOnce(deferred.promise); + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Other' })); + + // The spinner is spin-delayed, so it surfaces only after the delay window elapses. + await waitFor(() => expect(spinner()).toBeInTheDocument(), { timeout: 2000 }); + // A stood-down row stops being a button rather than rendering a disabled one. + expect(screen.queryByRole('button', { name: 'Sign out of all accounts' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'bob@example.com' })).toBeNull(); + expect(popup()).toBeInTheDocument(); + + deferred.resolve(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('replaces the accept button with a spinner while a suggestion is being joined', async () => { + const deferred = createDeferred(); + const suggestion = userSuggestions.data[0] as ReturnType; + suggestion.accept.mockReturnValueOnce(deferred.promise); + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Join' })); + + await waitFor(() => expect(spinner()).toBeInTheDocument(), { timeout: 2000 }); + expect(screen.queryByRole('button', { name: 'Join' })).toBeNull(); + + deferred.resolve(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('keeps the popover open and clears busy state when an action rejects', async () => { + const deferred = createDeferred(); + setActive.mockReturnValueOnce(deferred.promise); + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Other' })); + await waitFor(() => expect(spinner()).toBeInTheDocument(), { timeout: 2000 }); + + deferred.reject(new Error('setActive failed')); + + await waitFor(() => expect(spinner()).toBeNull(), { timeout: 2000 }); + expect(popup()).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeInTheDocument(); + }); + + it('mounts the paging sentinel only while more workspace pages remain', async () => { + renderUserButton(); + await open(); + expect(pagingRef).not.toHaveBeenCalled(); + }); + + it('hands the paging sentinel to the in-view ref when a list has a next page', async () => { + userMemberships = list([membership('org_1', 'Acme', 3)], 1, true); + renderUserButton(); + await open(); + + expect(pagingRef).toHaveBeenCalledWith(expect.any(HTMLElement)); + }); +}); From 2499c68f707f2b8d3a727570f0578369b0cadc3a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 13:21:37 -0400 Subject: [PATCH 02/31] 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. --- .../user-button.integration.test.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 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 e02cd311732..62b6ff417db 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 @@ -241,17 +241,18 @@ describe('UserButton (connected)', () => { expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/o/org_9' }); }); - it('switching to another account calls setActive with the session and closes', async () => { + 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' })); expect(setActive).toHaveBeenCalledWith({ session: 'sess_2', redirectUrl: '/after-switch' }); - await waitFor(() => expect(popup()).toBeNull()); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); }); - it('signing out of the active account calls signOut with its session id and closes', async () => { + it('signing out of the active account calls signOut with its session id', async () => { renderUserButton(); const act = await open(); @@ -259,20 +260,18 @@ 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(popup()).toBeNull()); }); - it('signing out of all accounts calls signOut with the after-sign-out url and closes', async () => { + it('signing out of all accounts calls signOut with the after-sign-out url', async () => { renderUserButton(); const act = await open(); await act.click(screen.getByRole('button', { name: 'Sign out of all accounts' })); expect(signOut).toHaveBeenCalledWith({ redirectUrl: '/after-sign-out' }); - await waitFor(() => expect(popup()).toBeNull()); }); - it('accepting an invitation accepts it, revalidates, and closes', async () => { + it('accepting an invitation accepts it, revalidates, and stays open', async () => { renderUserButton(); const act = await open(); const invitation = userInvitations.data[0] as ReturnType; @@ -281,10 +280,11 @@ describe('UserButton (connected)', () => { await waitFor(() => expect(invitation.accept).toHaveBeenCalledTimes(1)); expect(userInvitations.revalidate).toHaveBeenCalledTimes(1); - await waitFor(() => expect(popup()).toBeNull()); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); }); - it('accepting a suggestion accepts it, revalidates, and closes', async () => { + it('accepting a suggestion accepts it, revalidates, and stays open', async () => { renderUserButton(); const act = await open(); const suggestion = userSuggestions.data[0] as ReturnType; @@ -293,7 +293,8 @@ describe('UserButton (connected)', () => { await waitFor(() => expect(suggestion.accept).toHaveBeenCalledTimes(1)); expect(userSuggestions.revalidate).toHaveBeenCalledTimes(1); - await waitFor(() => expect(popup()).toBeNull()); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); }); it('reports an already-accepted suggestion instead of offering to join it again', async () => { @@ -389,7 +390,8 @@ describe('UserButton (connected)', () => { expect(screen.queryByRole('button', { name: 'Join' })).toBeNull(); deferred.resolve(); - await waitFor(() => expect(popup()).toBeNull()); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); }); it('keeps the popover open and clears busy state when an action rejects', async () => { From c6286c4423276e63e20654623cada23e6a8a7b1b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 13:35:09 -0400 Subject: [PATCH 03/31] test(ui): query the account menu trigger as a button --- .../user-button/__tests__/user-button.integration.test.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 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 62b6ff417db..af803fa05be 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 @@ -175,9 +175,7 @@ async function open() { return act; } -// Queried by label, not role: floating-ui gives any menu trigger inside another floating element -// `role="menuitem"`, so the `⋯` in the popover is not reachable as a button. -const accountMenu = () => screen.getByLabelText('Actions for alice@example.com'); +const accountMenu = () => screen.getByRole('button', { name: 'Actions for alice@example.com' }); /** Opens the `⋯` on the active account's row and clicks one of its actions. */ async function accountAction(act: ReturnType, label: string) { From 118fb3a108e537e4945d4c359ad69b4cde4cd74b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 14:02:58 -0400 Subject: [PATCH 04/31] refactor(ui): migrate the Mosaic Spinner to StyleX and give it a sm size The sizes track the Icon scale (sm 14px, md 16px) so a spinner can stand in for the icon it replaces, and the UserButton's trailing column is now one slot the width of the menu button, so the spinner, the active check, and the menu all sit on the same centre line. --- .../user-button/__tests__/user-button.integration.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 af803fa05be..573fa4e893c 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 @@ -166,7 +166,7 @@ function renderUserButton(props: UserButtonProps = {}) { const host = () => screen.getByTestId('host'); const trigger = () => screen.getByRole('button', { name: /Open account menu/ }); const popup = () => screen.queryByRole('dialog', { name: 'Account' }); -const spinner = () => popup()?.querySelector('[data-cl-spinner]') ?? null; +const spinner = () => popup()?.querySelector('.cl-spinner') ?? null; async function open() { const act = userEvent.setup(); From 3aa069d4aa59b0f940055cd34baf57b677e55048 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 15:08:20 -0400 Subject: [PATCH 05/31] 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. --- .../__tests__/user-button.integration.test.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 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 573fa4e893c..2273246d458 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 @@ -1,5 +1,5 @@ import type * as SharedReact from '@clerk/shared/react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -216,8 +216,13 @@ describe('UserButton (connected)', () => { renderUserButton(); await open(); + const surface = popup(); + if (!surface) { + throw new Error('expected the popover to be open'); + } + expect(screen.queryByRole('button', { name: 'Acme' })).toBeNull(); - expect(screen.getByText('Acme')).toBeInTheDocument(); + expect(within(surface).getByText('Acme')).toBeInTheDocument(); }); it('selecting an organization calls setActive without a redirect by default and closes the popover', async () => { From daca5a5fa0cd1a03c1198dc5902057547b9e1a7e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 15:50:46 -0400 Subject: [PATCH 06/31] 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. --- .../__tests__/user-button.integration.test.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 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 2273246d458..e30658d8eb3 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 @@ -222,7 +222,16 @@ describe('UserButton (connected)', () => { } expect(screen.queryByRole('button', { name: 'Acme' })).toBeNull(); - expect(within(surface).getByText('Acme')).toBeInTheDocument(); + // It heads the surface and is listed under it; neither one is something to click. + expect(within(surface).getAllByText('Acme')).toHaveLength(2); + }); + + it('heads the surface with the account where the user takes priority', async () => { + renderUserButton({ modePriority: 'user' }); + await open(); + + expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage organization' })).toBeNull(); }); it('selecting an organization calls setActive without a redirect by default and closes the popover', async () => { @@ -335,7 +344,7 @@ describe('UserButton (connected)', () => { renderUserButton(); const act = await open(); - await act.click(screen.getByRole('button', { name: 'Manage account' })); + await accountAction(act, 'Manage account'); expect(navigate).toHaveBeenCalledWith('/user-profile'); expect(popup()).toBeInTheDocument(); From de3217f9a29971eca2f12f78f9a06889618aa663 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 20:30:45 -0400 Subject: [PATCH 07/31] test(ui): follow the UserButton active-organization contract in the connected test --- .../__tests__/user-button.integration.test.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 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 e30658d8eb3..aa21421f4e9 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 @@ -30,6 +30,7 @@ interface FakeList { data: unknown[]; count: number; hasNextPage: boolean; + isLoading: boolean; revalidate: ReturnType; } @@ -38,7 +39,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; @@ -95,8 +96,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) }; } /** A promise whose settling is controlled by the test, to hold an async action in flight. */ @@ -123,7 +124,7 @@ beforeEach(() => { imageUrl: 'https://img/alice', }; session = { id: 'sess_1', checkAuthorization: vi.fn().mockReturnValue(true) }; - organization = { id: 'org_1' }; + organization = { id: 'org_1', name: 'Acme', imageUrl: '', 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); From cd06102b71cfe8b6c672ec439b20b45af842cbab Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 20:54:46 -0400 Subject: [PATCH 08/31] test(ui): carry organizationMemberships on the connected UserButton user fixture The controller now reads hasOrganizations off the user resource, so the mocked user needs the field the real one has. --- .../user-button/__tests__/user-button.integration.test.tsx | 3 +++ 1 file changed, 3 insertions(+) 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 aa21421f4e9..4cdbaa6ea4a 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 @@ -19,6 +19,7 @@ interface FakeUser { username: string | null; primaryEmailAddress: { emailAddress: string } | null; imageUrl: string; + organizationMemberships: unknown[]; } interface FakeSession { @@ -122,6 +123,7 @@ beforeEach(() => { username: 'alice', primaryEmailAddress: { emailAddress: 'alice@example.com' }, imageUrl: 'https://img/alice', + organizationMemberships: [{ id: 'orgmem_1' }], }; session = { id: 'sess_1', checkAuthorization: vi.fn().mockReturnValue(true) }; organization = { id: 'org_1', name: 'Acme', imageUrl: '', membersCount: 3 }; @@ -141,6 +143,7 @@ beforeEach(() => { username: null, primaryEmailAddress: { emailAddress: 'bob@example.com' }, imageUrl: 'https://img/bob', + organizationMemberships: [], }, }, ]; From 0e7feffa2f66f8b2ebbaff3a7d20ead4981e3198 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 4 Aug 2026 11:31:26 -0400 Subject: [PATCH 09/31] fix(ui): hold the Mosaic UserButton surface still while an action runs `setActive` swaps the active organization while its promise is still in flight, so the popup rearranged mid-action: the header renamed itself, the check jumped rows, and Invite came and went as the permission was re-read. The connected component now snapshots the controller when an action starts and renders that until it settles, so the result lands in one step. Two smaller faults fell out of the same interaction: - The spinner waited out a delay window before appearing, and the check raced ahead of it. Every action here is a network round trip, so there is nothing to debounce: `useSpinDelay` takes `delay: 0` and shows the value in the same pass, with `minDuration` still steadying it. - A row going busy swapped its host element from ` + {c.mode ?? 'combined'} + {String(c.open)} + {c.pendingKey ?? ''} + {c.activeOrganization?.organizationId ?? ''} - - - - - - + {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(); - 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(); + it('passes loading and hidden through until the model is ready', () => { + 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 }); + it('starts closed and opens and closes', () => { + render(); + expect(screen.getByTestId('open')).toHaveTextContent('false'); - const suggestions = JSON.parse(screen.getByTestId('suggestions').textContent ?? '[]'); - expect(suggestions[0]).toMatchObject({ - kind: 'suggestion', - id: 'sug_1', - organizationId: 'org_2', - name: 'Beta', - status: 'pending', - }); + fireEvent.click(screen.getByText('open')); + expect(screen.getByTestId('open')).toHaveTextContent('true'); - expect(invitations()[0]).toMatchObject({ - kind: 'invitation', - id: 'inv_1', - organizationId: 'org_3', - organizationName: 'Gamma', - status: 'pending', - }); + fireEvent.click(screen.getByText('close')); + 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(); - - 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('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(); - 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(); + it('lets the row be clicked again after a failure', async () => { + const onSelectOrganization = vi + .fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce(undefined); + render(); + fireEvent.click(screen.getByText('open')); 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' }); + 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 }); - }); + expect(onSelectOrganization).toHaveBeenCalledTimes(2); - 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' }); + 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' }); - }); - - // 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'); + expect(onSwitchSession).not.toHaveBeenCalled(); - 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(); + await act(async () => { + pending.resolve(undefined); + await tick(); + }); }); - // 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(); + it('refuses an action while the popup is closed', () => { + const onSelectOrganization = vi.fn(() => Promise.resolve()); + 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')); - - expect(openUserProfile).toHaveBeenCalledWith({ getContainer, customPages }); - }); + it('abandons an action dismissed mid-flight rather than reopening on its result', async () => { + const pending = deferred(); + const onSwitchSession = vi.fn(() => pending.promise); + render(); - // 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('open')); + fireEvent.click(screen.getByText('switch')); + await waitFor(() => expect(screen.getByTestId('pending')).toHaveTextContent('switch:sess_2')); - fireEvent.click(screen.getByText('manage-account')); - expect(navigate).toHaveBeenCalledWith('/account'); - expect(openUserProfile).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText('close')); + expect(screen.getByTestId('open')).toHaveTextContent('false'); - 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(); - - fireEvent.click(screen.getByText('manage-org')); + 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(); - 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(screen.getByTestId('active-org')).toHaveTextContent(''); + expect(screen.getByTestId('open')).toHaveTextContent('true'); - 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')); + 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/user-button.controller.tsx b/packages/ui/src/mosaic/user-button/user-button.controller.tsx index c8392db7887..10f1a7b5329 100644 --- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx @@ -1,307 +1,206 @@ -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'; - -// 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 | 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; +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; } -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 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; - -/** - * 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' }; - -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, for an app whose organizations are the whole product. An - * instance that forces organization selection withholds it either way; this cannot opt back in. - */ - hidePersonal?: boolean; +type UserButtonMachineEvent = + | { type: 'OPEN' } + | { type: 'CLOSE' } + | { + type: 'RUN'; + key: string; + frozen: UserButtonReadyModel; + run: () => Promise; + closeOnSuccess: 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; -} - -/** - * One rule for every surface Clerk can host: 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 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); -} +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) }, + }), + }, + }, +}); -function toMembership(organization: OrganizationResource): UserButtonMembership { - return { - kind: 'membership', - organizationId: organization.id, - name: organization.name, - imageUrl: organization.imageUrl || undefined, - membersCount: organization.membersCount, - }; -} +export type UserButtonControllerOptions = Pick & UserButtonMenuProps; -function toSession(sessionId: string, user: UserResource): UserButtonSession { - return { - sessionId, - name: displayName(user), - identifier: getIdentifier(user), - imageUrl: user.imageUrl, - }; -} +export type UserButtonController = + | { status: 'loading' } + | { status: 'hidden' } + | ({ 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(); - // 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 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 { 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 createOrganization = openOrNavigate({ - url: options?.createOrganizationUrl, - mode: options?.createOrganizationMode, - openModal: () => clerk.openCreateOrganization({ getContainer }), - buildUrl: () => clerk.buildCreateOrganizationUrl(), - navigate: router.navigate, - }); - - // Organizations, single-session and forced selection all come off the environment, and it - // hydrates on its own schedule. Waiting for it beats guessing at three answers and rearranging. - if (!isUserLoaded || !isSessionLoaded || !isOrgLoaded || !environment) { - return { status: 'loading' }; - } - - if (!user || !session) { - return { status: 'hidden' }; + if (model.status !== 'ready') { + return { status: model.status }; } - const { displayConfig, authConfig, organizationSettings } = environment; - // clerk-js refuses `setActive({ organization: null })` outright on an instance that forces - // organization selection, so there is no personal workspace to offer a way back to. - 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 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, - }, - ] - : [], + const close = () => send({ type: 'CLOSE' }); + + 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 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)]; - }); - - // `null` is Clerk's own name for the personal workspace, and it has no organization to resolve - // against, so it takes its own URL rather than the organizations'. - 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 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, - hidePersonal: forceOrganizationSelection || (options?.hidePersonal ?? false), - // `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, - additionalSessions, - paging: { - ref, - hasMore: Boolean(userMemberships.hasNextPage || userInvitations.hasNextPage || userSuggestions.hasNextPage), - }, - onSelectOrganization: organizationId => - clerk.setActive({ organization: organizationId, redirectUrl: afterSelectUrl(organizationId) }), - 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: manageAccount, - onManageOrganization: manageOrganization, - // Invite has no page of its own to route to, so it opens its modal even where managing the - // organization is routed to the app's own page. - onInviteMembers: canInviteMembers ? () => clerk.openInviteMembers({ getContainer }) : undefined, - // The instance can restrict who opens an organization, and the flag also goes false once a user - // reaches their creation limit, so it covers both ways the action can be unavailable. - 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 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?.(); - 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..a20483be53f --- /dev/null +++ b/packages/ui/src/mosaic/user-button/user-button.model.tsx @@ -0,0 +1,328 @@ +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'; + +// The controller awaits these one-shot actions to drive busy state, so the model exposes their +// promise; navigation callbacks stay fire-and-forget (`() => void`) and reach the view's DOM handlers. +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 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; + +/** + * 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' }; + +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, for an app whose organizations are the whole product. An + * instance that forces organization selection withholds it either way; 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; +} + +/** + * One rule for every surface Clerk can host: 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 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(); + // 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(); + // 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, + }); + + // Organizations, single-session and forced selection all come off the environment, and it + // hydrates on its own schedule. Waiting for it beats guessing at three answers and rearranging. + 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 })` outright on an instance that forces + // organization selection, so there is no personal workspace to offer a way back to. + 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 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. + const additionalSessions: UserButtonSession[] = (clerk.client?.signedInSessions ?? []).flatMap(s => { + const sessionUser = s.user; + if (!sessionUser || s.id === session.id) { + return []; + } + return [toSession(s.id, sessionUser)]; + }); + + // `null` is Clerk's own name for the personal workspace, and it has no organization to resolve + // against, so it takes its own URL rather than the organizations'. + 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 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, + hidePersonal: forceOrganizationSelection || (options?.hidePersonal ?? false), + // `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, + 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 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: manageAccount, + onManageOrganization: manageOrganization, + // Invite has no page of its own to route to, so it opens its modal even where managing the + // organization is routed to the app's own page. + onInviteMembers: canInviteMembers ? () => clerk.openInviteMembers({ getContainer }) : undefined, + // The instance can restrict who opens an organization, and the flag also goes false once a user + // reaches their creation limit, so it covers both ways the action can be unavailable. + 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 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?.(); + 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 eb7a0a27a92..f310109460b 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 } 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 { @@ -26,11 +24,11 @@ export interface UserButtonUserProfileProps { } /** - * Everything `` takes: where its profile surfaces open (`UserButtonControllerOptions`), + * Everything `` takes: where its profile surfaces open (`UserButtonModelOptions`), * 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 & +export type UserButtonProps = UserButtonModelOptions & UserButtonTriggerProps & UserButtonMenuProps & UserButtonModeProps & { @@ -95,7 +93,7 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { const { renderTriggerLabel, renderTriggerBadge, - mode: requestedMode, + mode, modePriority, userProfileProps, customMenuItems, @@ -104,127 +102,30 @@ 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, - }); - - // Nothing stands in for the button until Clerk answers: while it is loading, a signed-out visitor - // is indistinguishable from a session still resolving, so anything rendered here is a button - // promised to people who are never going to get one. `` is where an app that knows - // its own nav puts a placeholder. + // During loading we can't know if a visitor is signed-out, so we render no loading state. + // Users can put a placeholder in ``, and we could introduce a fallback prop here later. if (controller.status !== 'ready') { return <>{portals}; } - const close = () => send({ type: 'CLOSE' }); - - // A custom action is the app's to run, and whatever it opens takes over from here, so the popover - // goes with it. A link navigates away on its own. - const menuItems = customMenuItems?.map(item => - item.href === undefined - ? { - ...item, - onClick: () => { - close(); - item.onClick(); - }, - } - : item, - ); - - // Hands a one-shot callback to the machine, keyed by the affordance that owns it and carrying the - // controller to freeze on. Re-entry, clearing busy, and closing on success are all the machine's. - 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 26f9e5f9d1f..e2ff5df3728 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 edfef936a06..323c937d2fd 100644 --- a/packages/ui/src/mosaic/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -38,7 +38,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 f2ad4888aff90c4289a215c6bc03b23a2c25526f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 24 Aug 2026 12:49:26 -0400 Subject: [PATCH 31/31] style(ui): format the Mosaic UserButton controller --- .../__tests__/user-button.controller.test.tsx | 19 +++++--- .../user-button/user-button.controller.tsx | 46 +++++++++---------- 2 files changed, 36 insertions(+), 29 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 81000e52200..fe94b7bf2dc 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 @@ -101,10 +101,20 @@ describe('useUserButtonController', () => { }); it('forces user mode when organizations are disabled, whatever mode was asked for', () => { - const { rerender } = render(); + const { rerender } = render( + , + ); expect(screen.getByTestId('mode')).toHaveTextContent('user'); - rerender(); + rerender( + , + ); expect(screen.getByTestId('mode')).toHaveTextContent('organization'); }); @@ -178,10 +188,7 @@ describe('useUserButtonController', () => { }); it('lets the row be clicked again after a failure', async () => { - const onSelectOrganization = vi - .fn() - .mockRejectedValueOnce(new Error('boom')) - .mockResolvedValueOnce(undefined); + const onSelectOrganization = vi.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce(undefined); render(); fireEvent.click(screen.getByText('open')); 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 10f1a7b5329..947de532aed 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; - frozen: UserButtonReadyModel; - run: () => Promise; - closeOnSuccess: boolean; - }; + type: 'RUN'; + key: string; + frozen: UserButtonReadyModel; + run: () => Promise; + closeOnSuccess: boolean; + }; const { createMachine, assign, fromPromise } = setup(); @@ -123,13 +123,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 +141,22 @@ export function useUserButtonController( ) => fn ? (...args: Args) => - send({ - type: 'RUN', - key: keyFor(...args), - frozen: model, - run: async () => fn(...args), - closeOnSuccess, - }) + 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(); - } + close(); + fn(); + } : undefined; // Rendering the model the action froze on holds the popup still while it runs; the result