From 1bfe91259bc8d9115825dadfa04e0f2bc705155c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 13 Aug 2026 16:01:56 -0400 Subject: [PATCH 1/2] refactor(ui): rename the Mosaic UserButton controller to model The hook derives Clerk's resources into the shapes the view renders. That is a model, not a controller; the controller is the connected component that binds it to the view. --- .changeset/mosaic-user-button-mvc-layers.md | 2 ++ ...ler.test.tsx => user-button.model.test.tsx} | 14 +++++++------- .../user-button/__tests__/user-button.test.tsx | 18 +++++++++--------- .../__tests__/user-button.view.test.tsx | 4 ++-- ...on.controller.tsx => user-button.model.tsx} | 8 ++++---- .../ui/src/mosaic/user-button/user-button.tsx | 12 ++++++------ .../mosaic/user-button/user-button.types.ts | 4 ++-- .../mosaic/user-button/user-button.view.tsx | 8 ++++---- 8 files changed, 36 insertions(+), 34 deletions(-) create mode 100644 .changeset/mosaic-user-button-mvc-layers.md rename packages/ui/src/mosaic/user-button/__tests__/{user-button.controller.test.tsx => user-button.model.test.tsx} (98%) rename packages/ui/src/mosaic/user-button/{user-button.controller.tsx => user-button.model.tsx} (97%) diff --git a/.changeset/mosaic-user-button-mvc-layers.md b/.changeset/mosaic-user-button-mvc-layers.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-button-mvc-layers.md @@ -0,0 +1,2 @@ +--- +--- 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.model.test.tsx similarity index 98% rename from packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx rename to packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx index 1d728816eb5..49386033f68 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx @@ -2,8 +2,8 @@ import type * as SharedReact from '@clerk/shared/react'; import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { UserButtonControllerOptions } from '../user-button.controller'; -import { useUserButtonController } from '../user-button.controller'; +import type { UserButtonModelOptions } from '../user-button.model'; +import { useUserButtonModel } from '../user-button.model'; interface FakeUser { id: string; @@ -46,7 +46,7 @@ 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 controller has to sit out. +// 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. @@ -100,7 +100,7 @@ vi.mock('@clerk/shared/react', async importOriginal => { }; }); -// The controller reads its three paginated lists through the shared in-view helper, so the fetch +// 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: () => ({ userMemberships, userInvitations, userSuggestions, ref: pagingRef }), @@ -183,8 +183,8 @@ afterEach(() => { vi.clearAllMocks(); }); -function Harness(options: UserButtonControllerOptions = {}) { - const c = useUserButtonController(options); +function Harness(options: UserButtonModelOptions = {}) { + const c = useUserButtonModel(options); if (c.status !== 'ready') { return {c.status}; } @@ -298,7 +298,7 @@ function activeOrganization() { return JSON.parse(screen.getByTestId('active-org').textContent ?? 'null'); } -describe('useUserButtonController', () => { +describe('useUserButtonModel', () => { it('is loading until the user, session, and organization are all loaded', () => { isUserLoaded = false; const { rerender } = render(); diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx index 2583acccf32..917d1da886e 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx @@ -2,15 +2,15 @@ import { render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { UserButton } from '../user-button'; -import type { UserButtonController } from '../user-button.controller'; +import type { UserButtonModel } from '../user-button.model'; -let controller: UserButtonController; +let model: UserButtonModel; -vi.mock('../user-button.controller', () => ({ - useUserButtonController: () => controller, +vi.mock('../user-button.model', () => ({ + useUserButtonModel: () => model, })); -// The container's own job is which of the three controller states renders what, so the surface is +// The controller's own job is which of the three model states renders what, so the surface is // stubbed out and the view's own tests cover it. vi.mock('../user-button.view', () => ({ userButtonBusyKeys: { @@ -24,7 +24,7 @@ vi.mock('../user-button.view', () => ({ UserButtonView: () => , })); -function ready(): UserButtonController { +function ready(): UserButtonModel { return { status: 'ready', organizationsEnabled: true, @@ -43,7 +43,7 @@ function ready(): UserButtonController { describe('UserButton', () => { beforeEach(() => { - controller = { status: 'loading' }; + model = { status: 'loading' }; }); it('stands the fallback in while Clerk is still answering', () => { @@ -55,14 +55,14 @@ describe('UserButton', () => { // Signing out is an answer, not a wait. Holding the placeholder there would promise a button to // someone who is never going to get one. it('drops the fallback once nobody is signed in', () => { - controller = { status: 'hidden' }; + model = { status: 'hidden' }; render(} />); expect(screen.queryByTestId('fallback')).not.toBeInTheDocument(); expect(screen.queryByTestId('view')).not.toBeInTheDocument(); }); it('renders the surface once the session is ready', () => { - controller = ready(); + model = ready(); render(} />); expect(screen.getByTestId('view')).toBeInTheDocument(); expect(screen.queryByTestId('fallback')).not.toBeInTheDocument(); diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx index def6553349f..11af8c2f55b 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx @@ -35,8 +35,8 @@ const gamma = { const beta = { kind: 'suggestion', id: 'sug_1', organizationId: 'org_4', name: 'Beta', status: 'pending' } as const; /** - * Every callback the connected container passes, so a test opts a surface *out* of an affordance - * rather than having to opt into it. `combined` is the container's own default. + * Every callback the connected controller passes, so a test opts a surface *out* of an affordance + * rather than having to opt into it. `combined` is the controller's own default. */ function renderView(props: Partial = {}) { return render( diff --git a/packages/ui/src/mosaic/user-button/user-button.controller.tsx b/packages/ui/src/mosaic/user-button/user-button.model.tsx similarity index 97% rename from packages/ui/src/mosaic/user-button/user-button.controller.tsx rename to packages/ui/src/mosaic/user-button/user-button.model.tsx index 024c92525df..75b13f4f8ae 100644 --- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.model.tsx @@ -17,7 +17,7 @@ import type { UserButtonSuggestion, } from './user-button.types'; -// Promise-returning so the container can drive busy state. Navigation callbacks stay fire-and-forget. +// Promise-returning so the controller can drive busy state. Navigation callbacks stay fire-and-forget. interface UserButtonAsyncCallbacks { onSelectOrganization?: (organizationId: string | null) => void | Promise; onSwitchSession?: (sessionId: string) => void | Promise; @@ -27,7 +27,7 @@ interface UserButtonAsyncCallbacks { onAcceptInvitation?: (invitationId: string) => void | Promise; } -export type UserButtonController = +export type UserButtonModel = | { status: 'loading' } | { status: 'hidden' } | (UserButtonData & @@ -55,7 +55,7 @@ type CreateOrganizationMode = | { createOrganizationUrl: string; createOrganizationMode?: 'navigation' } | { createOrganizationUrl?: never; createOrganizationMode?: 'modal' }; -export type UserButtonControllerOptions = UserProfileMode & +export type UserButtonModelOptions = UserProfileMode & OrganizationProfileMode & CreateOrganizationMode & { afterSelectOrganizationUrl?: AfterSelectUrl; @@ -121,7 +121,7 @@ function toSession(sessionId: string, user: UserResource): UserButtonSession { }; } -export function useUserButtonController(options?: UserButtonControllerOptions): UserButtonController { +export function useUserButtonModel(options?: UserButtonModelOptions): UserButtonModel { const { isLoaded: isUserLoaded, user } = useUser(); const { isLoaded: isSessionLoaded, session } = useSession(); const { isLoaded: isOrgLoaded, organization } = useOrganization(); diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx index 554779a3187..ead5e904af0 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -4,13 +4,13 @@ import type { ReactElement, ReactNode } from 'react'; import { useState } from 'react'; import { useSpinDelay } from '../hooks/useSpinDelay'; -import { type UserButtonControllerOptions, useUserButtonController } from './user-button.controller'; +import { type UserButtonModelOptions, useUserButtonModel } from './user-button.model'; import type { UserButtonMenuProps, UserButtonModeProps } from './user-button.types'; import type { UserButtonTriggerProps } from './user-button.view'; import { userButtonBusyKeys, UserButtonView } from './user-button.view'; /** Everything `` takes: profile routing, trigger content, and the app's own menu rows. */ -export type UserButtonProps = UserButtonControllerOptions & +export type UserButtonProps = UserButtonModelOptions & UserButtonTriggerProps & UserButtonMenuProps & Pick & { @@ -79,19 +79,19 @@ export type UserButtonProps = UserButtonControllerOptions & export function UserButton(props: UserButtonProps = {}): ReactElement | null { const { renderTriggerLabel, renderTriggerBadge, modePriority, customMenuItems, menuItemOrder, fallback, ...options } = props; - const controller = useUserButtonController(options); + const model = useUserButtonModel(options); const [open, setOpen] = useState(false); const [pendingKey, setPendingKey] = useState(null); // Re-entry is guarded on the immediate `pendingKey`; only the view's feedback is delayed. const displayPendingKey = useSpinDelay(pendingKey); - if (controller.status === 'loading') { + if (model.status === 'loading') { return <>{fallback}; } // Signed out is an answer, so the placeholder goes too rather than promising a button. - if (controller.status === 'hidden') { + if (model.status === 'hidden') { return null; } @@ -137,7 +137,7 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { onAcceptSuggestion, onAcceptInvitation, ...data - } = controller; + } = model; return ( `select-org:${organizationId ?? 'personal'}`, @@ -601,7 +601,7 @@ function PendingRow({ busyKey, name, imageUrl, actionLabel, onAccept, note }: Pe // Every other affordance here swaps its icon for a spinner, but this one is a labelled // button, so the spinner goes inside it rather than taking the row's trailing edge — the // press and the thing that reports it stay the same element. `pendingKey` is already - // spin-delayed by the container, so this asks for no second delay of its own. + // spin-delayed by the controller, so this asks for no second delay of its own. Date: Thu, 13 Aug 2026 16:03:45 -0400 Subject: [PATCH 2/2] refactor(ui): rename the Mosaic UserButton container to controller It holds the interaction state and binds the model to the view, so it is the controller. The filename now says so. --- .../{user-button.test.tsx => user-button.controller.test.tsx} | 2 +- .../user-button/{user-button.tsx => user-button.controller.tsx} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/ui/src/mosaic/user-button/__tests__/{user-button.test.tsx => user-button.controller.test.tsx} (97%) rename packages/ui/src/mosaic/user-button/{user-button.tsx => user-button.controller.tsx} (100%) diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx similarity index 97% rename from packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx rename to packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx index 917d1da886e..10043907d75 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { UserButton } from '../user-button'; +import { UserButton } from '../user-button.controller'; import type { UserButtonModel } from '../user-button.model'; let model: UserButtonModel; diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.controller.tsx similarity index 100% rename from packages/ui/src/mosaic/user-button/user-button.tsx rename to packages/ui/src/mosaic/user-button/user-button.controller.tsx