Skip to content

feat(ui): add UserButton controller - #9185

Open
alexcarpenter wants to merge 18 commits into
carp/account-button-switcherfrom
carp/account-button-controller
Open

feat(ui): add UserButton controller#9185
alexcarpenter wants to merge 18 commits into
carp/account-button-switcherfrom
carp/account-button-controller

Conversation

@alexcarpenter

@alexcarpenter alexcarpenter commented Jul 16, 2026

Copy link
Copy Markdown
Member

Summary

Stacked on #9184 (the presentational view). Adds the controller layer that connects UserButton to live Clerk data, plus its tests (TDD).

  • useUserButtonController() (user-button.controller.tsx) — a plain hook (no machine) returning a 'loading' | 'hidden' | 'ready' discriminated union. When ready it carries UserButtonData & UserButtonCallbacks 1:1 with the contract in user-button.view.tsx.
  • UserButton (user-button.tsx) — the connected container. Owns popover open state and close-on-success (Option A: wraps each one-shot callback with .finally(close); manage/create navigations do not auto-close).
  • Renamed the view's all-in-one export UserButtonUserButtonView, reserving the bare UserButton for the connected container. Updated the swingset story + mdx.

Data mapping (Clerk → contract)

  • activeSession from useUser() + useSession(), name fallback first+last > username > email.
  • activeOrganizationId from useOrganization() (null = personal).
  • memberships/suggestions/invitations from useOrganizationList(organizationListParams) (reused from the legacy switcher).
  • hasOrganizations from userMemberships.count (never array length).
  • additionalSessions from clerk.client.signedInSessions, excluding the active user's session.
  • membershipRequestCount only on the active-org row and only with org:sys_memberships:manage.
  • planLabel / upgradeable deferred (undefined).

Actions (navigation mode)

setActive for select/switch, signOut for sign-out, router.navigate(clerk.build*Url()) for manage/create, .accept() + revalidate() for invitations/suggestions.

Tests

12 controller tests (__tests__/user-button.controller.test.tsx) using the mosaic vi.mock('@clerk/shared/react') harness (copied from organization-profile-delete-section.controller.test.tsx). Written failing first, then implemented to green.

Notes / deviations from the handoff

Two points where the handoff wasn't directly implementable; resolved as follows:

  1. onSelectOrganization navigation. The handoff referenced displayConfig.afterSelectOrganizationUrl, but that field only exists as a component prop, not on the environment. Per decision, navigation goes through setActive({ organization, redirectUrl }) (matching the legacy switcher), using displayConfig.afterCreateOrganizationUrl as the redirect. Fully type-safe, no cast.
  2. Container loading render. The handoff described loading → <UserButtonView status="loading" />, but the view requires activeSession (which is only present when ready) and the view was out of scope to restructure. The container renders null while loading/hidden and the view only when ready. The controller still exposes status: 'loading' for future use.

Checklist

@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview Aug 5, 2026 6:39pm
swingset Ready Ready Preview Aug 5, 2026 6:39pm

Request Review

@changeset-bot

changeset-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: b9b02e1

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a04aed4-0d7c-4b2c-a512-143674d4ca8d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the ui label Jul 16, 2026
@alexcarpenter
alexcarpenter force-pushed the carp/account-button-controller branch from 8786841 to ba22f92 Compare July 16, 2026 21:54
@alexcarpenter
alexcarpenter force-pushed the carp/account-button-controller branch from ba22f92 to c9bdf23 Compare July 30, 2026 18:38
@alexcarpenter alexcarpenter changed the title feat(ui): add AccountButton controller feat(ui): add UserButton controller Jul 30, 2026
@alexcarpenter
alexcarpenter force-pushed the carp/account-button-controller branch from c9bdf23 to 93aa6f2 Compare July 31, 2026 13:32
@alexcarpenter
alexcarpenter force-pushed the carp/account-button-controller branch from 93aa6f2 to 4643795 Compare August 3, 2026 15:08
@alexcarpenter
alexcarpenter force-pushed the carp/account-button-controller branch from 4643795 to 4467f3d Compare August 3, 2026 15:38
@alexcarpenter
alexcarpenter force-pushed the carp/account-button-controller branch from 4467f3d to fbd5c2f Compare August 3, 2026 17:09
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-08-05T18:43:14.539Z

Summary

Metric Count
Packages analyzed 19
Packages with changes 0
🔴 Breaking changes 0
🟡 Non-breaking changes 0
🟢 Additions 0

No API Changes Detected

All packages have stable APIs with no detected changes.


Report generated by Break Check

Last ran on b9b02e1.

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.
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.
`showLabel` becomes `renderTriggerLabel`, and the plan badge gets its own
`renderPlanBadge`. The badge is part of the label, so it needs both.
…ccount

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.
…g state

`useOrganization()` resolves before the organization list does, so the controller
describes the active organization from the resource itself rather than leaving the
view to find it in a list that has not arrived. `organizationsLoading` covers that
window, and revoked or expired invitations are dropped since accepting is all an
invitation row offers.

Accepting an invitation joins the organization, so it now revalidates the membership
list alongside the invitation one.
…ists load

The membership count is 0 until the first page lands, so the surface had no way to tell an account with no organizations from one whose list had yet to arrive, and opened a workspace section under both. The user resource carries its own memberships, so the question is settled before any request goes out; the fetched count still counts, in case the resource is behind the server.
Managing an account and managing an organization both navigated to Clerk's built-in profile URLs. Both now open the corresponding modal instead, which is what `<UserButton>` and `<OrganizationSwitcher>` each already do, so returning from one puts you back where you were rather than on another page.

Apps that would rather route take `userProfileUrl` and `organizationProfileUrl`, in the same url-plus-mode shape as the existing components: a URL is the whole opt-in to navigation, and `modal` forbids one, so the pair cannot contradict itself. The two profiles resolve apart, so routing one leaves the other a modal.

Inviting members follows wherever managing the organization goes. It is the other way into administering the same organization, so splitting them would send one to the app's own page and the other to Clerk's.
The profile modals opened into `document.body`, so an app that mounts the button inside
its own dialog or popover got the modal rendered behind it. Both now open into the portal
root from `usePortalRoot`, matching what the pre-Mosaic `<UserButton>` and
`<OrganizationSwitcher>` pass as `getContainer`.
Switching into an organization was a one-way door: nothing on the surface cleared the
active one. The controller now offers the account's own workspace as a selectable row,
and withholds it where there are no organizations to leave.
Selecting the personal workspace had nowhere to land, since a redirect resolved against an
organization has nothing to resolve. `afterSelectPersonalUrl` gives it its own, taking the
same path template or builder function as `afterSelectOrganizationUrl` and resolving
against the user.
…s required

An instance with force organization selection enabled has no personal workspace to
return to: clerk-js refuses `setActive({ organization: null })` outright there, so the
row stood in the list and did nothing when clicked. It is now withheld, the way the
existing OrganizationSwitcher hides personal under the same setting.
`hidePersonal` leaves the account's own workspace out of the list, for an app whose
organizations are the whole product. It joins the instance setting rather than
overriding it: where organization selection is forced there is no personal workspace
either way, and passing `false` cannot opt back into one.
The row was offered unconditionally, so a user whose instance restricts organization creation, or who is at their creation limit, landed on a page that turns them away. Legacy gates the same action on `user.createOrganizationEnabled`.
Create organization always navigated to Clerk's hosted page. It now resolves the same way the two profile surfaces do: a modal by default, or navigation when `createOrganizationUrl` routes it. The shared helper is renamed `openOrNavigate`, since it no longer serves only profiles.
Invite has no page of its own, so it opens Clerk.openInviteMembers() rather
than following organizationProfileUrl into the app's own page.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant