feat(ui): connect the Mosaic UserButton and its custom UserProfile pages - #9191
feat(ui): connect the Mosaic UserButton and its custom UserProfile pages#9191alexcarpenter wants to merge 26 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: fb1eb38 The changes in this PR will be included in the next version bump. This PR includes changesets to release 0 packagesWhen 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 |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository YAML (base), Repository UI (inherited) Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
ApprovabilityVerdict: Approved This PR adds integration tests for the AccountButton component with no production code changes. The empty changeset confirms no packages are affected, making this a low-risk test-only addition. You can customize Macroscope's approvability policy. Learn more. |
Dismissing prior approval to re-evaluate 088fc5a
22e6748 to
4f890d8
Compare
@clerk/astro
@clerk/backend
@clerk/chrome-extension
@clerk/clerk-js
@clerk/electron
@clerk/electron-passkeys
@clerk/eslint-plugin
@clerk/expo
@clerk/expo-google-signin
@clerk/expo-passkeys
@clerk/express
@clerk/fastify
@clerk/hono
@clerk/localizations
@clerk/nextjs
@clerk/nuxt
@clerk/react
@clerk/react-router
@clerk/shared
@clerk/tanstack-react-start
@clerk/testing
@clerk/ui
@clerk/upgrade
@clerk/vue
commit: |
API Changes Report
Summary
No API Changes DetectedAll packages have stable APIs with no detected changes. Report generated by Break Check Last ran on |
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.
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 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.
Description
Stacked on #9185. Covers the connected
UserButtonthrough the real view and the real controller against a mocked Clerk, settles the popover behaviour that coverage exposed, and carries the app's own pages into the UserProfile the button opens.One flow, one machine. 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.
user-button.machine.tsholds both instead of twouseStates in the container. Re-entry, clearing busy, and closing on success stop being hand written there:RUNis 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.The surface holds still while an action runs. The view renders the controller the action started from.
setActiveswaps the active organization while its promise is in flight, so the live controller would otherwise rearrange the popup under the pointer: the header renaming itself, the check jumping rows, Invite coming and going as the permission is re-read. The result lands in one step when the action settles.The popover closes behind whatever it opens. A modal or another page takes over from there, so there is nothing left for the popover to show. Left up, it would sit over the very surface it just opened.
A failure leaves the popover open so the row can be clicked again. Nothing reports what went wrong yet; the error surface is its own change.
modereaches the connected button. Organizations turned off at the instance leave nothing for an organization surface to lead with or to list, so the button is the account's whatevermodeasked for. clerk-js withholds its own<OrganizationSwitcher>at the mount boundary, and nothing mounts this one, so the gate lives in the container.Custom UserProfile pages
userProfilePropsputs your own pages and links into the UserProfile the button opens, and orders that profile's navigation.Plain objects rather than the
<UserButton.UserProfilePage>children the pre-Mosaic button takes. Every entry has apath, which is what identifies it: withcontentit is a page inside the profile, withhrefit is a link out of it.iconis optional on both.pageOrderentries are ids:account,security,billingorapiKeysfor a built-in page, or a custom entry'spath.Objects also drop the
'use client'requirement the children API carries.UserButton.UserProfilePagemeans dotting into a client module, which RSC forbids ("Cannot access UserProfilePage on the server"), so describing config forced the directive onto the consumer's file and pulled the page into the client bundle. A prop has nothing to dot into: this renders from a server component, andcontentcan stay a server component too.The profile still opens in clerk-js's own React root, which cannot render a node from the host app's tree, so
useCustomPagesbridges the two: each entry goes out as themount/unmountpair clerk-js already takes, and the host tree portals the content into the element it hands back. The portals hang off the container rather than the popover, since they have to outlive whatever opened the profile. When a Mosaic UserProfile lands in-tree, the bridge goes and the props stay.None of this is public surface. The experimental entry exports
UserButtonandUserButtonPropsand nothing else, so the hooks and the page types stay internal.Two things worth a look in review:
iconoff would silently cost you the page.pageOrderhas to send the built-ins that were left out too, appended after the ones that were named. That means knowing which built-ins the instance actually shows, which is whatuseUserProfilePagescomputes, behind the same shared guards clerk-js uses. It cannot seeshouldShowBilling(paid plans / past subscriptions, fetched inside the profile), so an instance with billing enabled but no visible plans gets one dev-only log and the page is dropped, which is what should happen to it anyway.Checklist
pnpm testruns as expected.pnpm buildruns as expected.Type of change