Skip to content

feat(ui): add UserButton controller - #9185

Open
alexcarpenter wants to merge 59 commits into
mainfrom
carp/account-button-controller
Open

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

Conversation

@alexcarpenter

@alexcarpenter alexcarpenter commented Jul 16, 2026

Copy link
Copy Markdown
Member

Description

The PR #9191 was folded into this one for a final review. See that PR description for additional details.

Stacked on #9184. Connects the UserButton view to live Clerk data.

useUserButtonController() returns a 'loading' | 'hidden' | 'ready' union. When it is ready it carries the view's data contract and the callback behind every row. user-button.tsx is the connected container that owns the popover.

Where the data comes from:

  • activeSession from useUser() and useSession(). The name follows Clerk's own order: first and last name, then username, then the identifier.
  • activeOrganization from useOrganization(), where null is the personal workspace.
  • memberships, suggestions, and invitations from useOrganizationList(), paged as the list scrolls.
  • hasOrganizations from the user resource, so it can answer before those lists load.
  • additionalSessions from the client, without the active one.
  • The membership request count only on the active organization row, and only with org:sys_memberships:manage.

What the rows do:

  • Picking a workspace or an account calls setActive. Signing out calls signOut. Invitations and suggestions accept in place and revalidate the list.
  • Manage account, manage organization, create organization, and invite members open Clerk's own modals, portalled out of the popover so they outlive it. Passing a URL routes to a page of your own instead, and that is the whole opt-in: userProfileUrl, organizationProfileUrl, createOrganizationUrl. afterSelectOrganizationUrl and afterSelectPersonalUrl say where picking a workspace lands.
  • Create organization is withheld from a user without the permission to create one. The personal row is withheld where an organization is required, and hidePersonal withholds it by request.
  • customMenuItems reach the menu through the container, and a custom action closes the popover behind whatever it opens.

The button renders nothing until Clerk answers. While it loads, a signed-out visitor and a session still resolving are indistinguishable, so anything rendered then is a button promised to people who are never going to get one. <ClerkLoading> is where an app that knows its own nav puts a placeholder.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

@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 25, 2026 11:07am
swingset Ready Ready Preview Aug 25, 2026 11:07am

Request Review

@changeset-bot

changeset-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 23d2212

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 0 packages

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

Not sure what this means? Click here to learn what changesets are.

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

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Added useUserButtonController with Clerk-backed user, session, organization, invitation, and suggestion state. Added navigation, modal, switching, sign-out, authorization, pagination, and acceptance handlers. Added the client-side UserButton component with pending-state and action guards. Added controller and component tests, plus changeset metadata.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 76060

The PR connects UserButton actions to live account and organization data; the only remaining concern is limited interaction-test coverage for pending actions and closing after selection. No actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.75% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 4 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the primary change: adding the UserButton controller. It is concise and directly related to the changeset.
Description check ✅ Passed The description directly explains the UserButton controller, connected container, supported data sources, actions, states, and behavior implemented by the changeset.
Full details: Docstring Coverage

Explanation

Docstring coverage is 18.75% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 4 files. (1 skipped: 1 unsupported.)


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
alexcarpenter and others added 28 commits August 25, 2026 13:04
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.
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.
…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.
…ser fixture

The controller now reads hasOrganizations off the user resource, so the mocked user needs the field the real one has.
`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 `<button>` to `<div>`,
  remounting the subtree and dropping the avatar back to its initials for
  the length of the action. A row that stands down now stays the button it
  was, disabled, and `Avatar.Image` resolves a browser-cached `src` in a
  layout effect so neither a remount nor a swap flashes the fallback.
The popover stayed up behind the surface it opened. Managing, inviting, creating an
organization, and adding an account now close it on the way out, whether they open a modal
or navigate.
The connected test drives the real controller against a mocked Clerk, which is what
makes it worth having and also what makes it slow. Cases that only ever asserted what
the popover renders now sit in the view test, leaving the connected one to prove the
layers compose. Also covers `hidePersonal` reaching the popover through the container.
UserButtonProps picked only modePriority off the root, so the connected component was hard-wired to the combined surface and the orgs/user modes were reachable only by composing UserButtonView directly.
Presses a custom row on the connected UserButton and checks the app's
callback runs and the popover closes behind it.
An instance with organizations turned off has none to lead with or list,
so the button is the account's whatever `mode` asked for — `orgs` would
otherwise render an empty shell of a switcher. clerk-js withholds its own
OrganizationSwitcher at the mount boundary, which an app importing this
one never crosses, so the gate lives in the component.
The popover's open state and the one action in flight are the same flow, so
they now live in one machine instead of two useStates. Re-entry, clearing
busy, and closing on success stop being hand-written in the container: RUN is
simply unhandled while busy, and busy is only reachable from open.

Dismissing the popover mid-action now abandons the result rather than letting
it land in a surface that is already gone.
Follows the view's rename of `'orgs'` to `'organization'`.

Also corrects the integration suite's opening comment, which attributed
close-on-success to the container and had the navigation case backwards.
Carries the app's own pages and links into the profile the UserButton opens,
through useCustomPages and the built-in page list it orders them against.
Moves useCustomPages and useUserProfilePages out of the shared mosaic hooks folder into user-button.pages, and routes the custom page order through the same applyOrder rule the menu uses, which also stops a custom page named after a built-in from being sent twice.

@Ephem Ephem left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did some AI-assisted reviewing and found a few things to dig into. I'll follow this up with stepping through everything myself as well.

Happy to tackle a few of these if you'd like after I've gotten through the full review.

Comment on lines +117 to +119
if (model.status !== 'ready') {
return { status: model.status };
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this happens before respecting the frozen state I think this is going to revert back to 'loading' during the transitive state when a navigation is involved after setActive.

That means we'll pop the fallback/null during that window.

Reverting to 'loading' also isn't covered by tests currently.

Image

Comment on lines +196 to +197
onSignOutSession: runAction(userButtonBusyKeys.signOutSession, onSignOutSession),
onSignOutAll: runAction(userButtonBusyKeys.signOutAll, onSignOutAll),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think onSignOutAll should have closeOnSuccess: true, and onSignOutSession should have data.additionalSessions.length === 0.

Otherwise, the state will still be 'open', but the component unmounts. If the user signs in again while UserButton is still mounted, the popup will open again after the sign in which feels off.

Comment on lines +234 to +235
const selected = membershipData.find(m => m.organization.id === organizationId)?.organization;
return selected ? resolveAfterSelectUrl(options?.afterSelectOrganizationUrl, selected) : undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is called via onSelectOrganization and one of the callsites for that is when switching to an recently accepted invitation. When it's an invitation, there's no guarantee the organization is in membershipData by the time this gets called, resulting in an undefined url and no navigation.

This can happen on fast clicks on slow networks because we don't await the revalidates further down, see separate comment.

Comment on lines +298 to +299
void userInvitations.revalidate?.();
void userMemberships.revalidate?.();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should not be voided, instead return a Promise.all(...) from the finally so we wait for the revalidate. This fixes a bug mentioned above, but it also fixes a case where the UI goes back to showing "Join" again right after already having joined, and the possibility of trying to accept invitations twice.

Or put differently, by not waiting for the revalidation, we are not getting the benefits of frozen.

Not sure if we also want to add a separate catch with a noop for the revalidations, otherwise the action will be treated as failed just because revalidations failed. 🤔 Both versions has tradeoffs.

// 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) } },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if you reopen the popup while the action you were taking is still ongoing? We'd want to still show pending then right?

I think we might need to split it into two separate states?

popup: open | closed
action: idle | busy

return;
}
// `redirectUrl` decorated for us; taking the callback takes the Safari ITP refresh with it.
await router.navigate(decorateUrl(displayConfig.afterSwitchSessionUrl));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tries to navigate even if afterSwitchSessionUrl is empty, which might navigate to the same page as we are on, old behavior in useMultisessionActions was to call it conditionally.

Also, are we missing a afterSwitchSessionUrl prop on the component? Is that intentional?

Comment on lines +58 to +69
export type UserButtonModelOptions = UserProfileMode &
OrganizationProfileMode &
CreateOrganizationMode & {
afterSelectOrganizationUrl?: AfterSelectUrl<OrganizationResource>;
/** Where selecting the personal workspace lands. Resolved against the user, not an organization. */
afterSelectPersonalUrl?: AfterSelectUrl<UserResource>;
/**
* Leaves the personal workspace out. An instance that forces organization selection withholds it
* either way, so this cannot opt back in.
*/
hidePersonal?: boolean;
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had AI go through and map props to the old button, these are the discrepancies it found. Some of this might be intentional, or saved for follow-ups but it's worth being explicit about so I thought I'd post the list, maybe you can shed some light on which are missing intentionally and not?

Likely worth adding

  • defaultOpen
    • Supported by UserButtonRoot, but not exposed by the connected UserButton or controller.
  • signInUrl
    • Legacy uses the component override for “Add account” and session-task URLs.
    • Mosaic always uses clerk.buildSignInUrl().
  • Additional userProfileProps
    • Mosaic only accepts customPages and pageOrder.
    • Legacy also forwards:
      • additionalOAuthScopes
      • apiKeysProps
      • appearance for the opened UserProfile modal
  • Custom menu open / profile start path
    • Legacy custom items can open a specific UserProfile page.
    • Mosaic items only support href or onClick.

Because Mosaic UserButton also replaces OrganizationSwitcher functionality, these are missing too:

  • afterCreateOrganizationUrl
    • Controls where Clerk navigates after creating an organization.
    • This differs from createOrganizationUrl, which controls whether clicking Create opens Clerk’s modal or navigates to an app page.
  • skipInvitationScreen
    • Configures the Clerk create-organization modal.
  • afterLeaveOrganizationUrl
    • Used after leaving an organization through OrganizationProfile.
  • organizationProfileProps
    • Legacy forwards custom pages and appearance into the opened OrganizationProfile.

Comment on lines +243 to +246
activeOrganization: organization ? toMembership(organization) : null,
// The user resource settles this before the paginated list answers; the count covers a stale resource.
hasOrganizations: user.organizationMemberships.length > 0 || (userMemberships.count ?? 0) > 0,
hidePersonal: forceOrganizationSelection || (options?.hidePersonal ?? false),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a state I don't think is modelled here which is "No organization selected". I think this is when forceOrganizationSelection is true, but organization is null. Not sure when this happens (deleted orgs?), but I know the old UserButton handles it.

Requires changes in the view too, but this is within the PR diff and I don't think we are providing everything we need from here (forceOrganizationSelection).

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.

2 participants