feat(ui): connect the Mosaic UserButton and its custom UserProfile pages - #9191

Closed
alexcarpenter wants to merge 30 commits into
carp/account-button-controllerfrom
carp/account-button-integration
Closed

feat(ui): connect the Mosaic UserButton and its custom UserProfile pages#9191
alexcarpenter wants to merge 30 commits into
carp/account-button-controllerfrom
carp/account-button-integration

Conversation

@alexcarpenter

@alexcarpenteralexcarpenter commented Jul 17, 2026

Copy link
Copy Markdown
Member

Description

Stacked on #9185. Covers the connected UserButton through 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.ts holds both instead of two useStates in the container. Re-entry, clearing busy, and closing on success stop being hand written there: 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.

The surface holds still while an action runs. The view renders the controller the action started from. setActive swaps 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.

mode reaches 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 whatever mode asked 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

userProfileProps puts your own pages and links into the UserProfile the button opens, and orders that profile's navigation.

<UserButtonuserProfileProps={{customPages: [{label: 'Terms',path: 'terms',icon: <FileIcon/>,content: <Terms/>},{label: 'Docs',path: 'docs',href: 'https://clerk.com/docs',icon: <BookIcon/>},],pageOrder: ['account','terms','security','docs'],}}/>

Plain objects rather than the <UserButton.UserProfilePage> children the pre-Mosaic button takes. Every entry has a path, which is what identifies it: with content it is a page inside the profile, with href it is a link out of it. icon is optional on both. pageOrder entries are ids: account, security, billing or apiKeys for a built-in page, or a custom entry's path.

Objects also drop the 'use client' requirement the children API carries. UserButton.UserProfilePage means 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, and content can 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 useCustomPages bridges the two: each entry goes out as the mount/unmount pair 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 UserButton and UserButtonProps and nothing else, so the hooks and the page types stay internal.

Two things worth a look in review:

  • The icon callbacks go out whether or not there is an icon. clerk-js decides what an item is from which callbacks are present and drops one missing an icon pair as invalid, so without them, leaving icon off would silently cost you the page.
  • clerk-js puts every built-in page it was not asked to move ahead of everything it was, so pageOrder has 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 what useUserProfilePages computes, behind the same shared guards clerk-js uses. It cannot see shouldShowBilling (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 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: test coverage

@vercel

vercelBot commented Jul 17, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreviewAug 25, 2026 11:02am
swingsetReadyReadyPreviewAug 25, 2026 11:02am

Request Review

@changeset-bot

changeset-botBot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 30adecc

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

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: 2a7f3fe3-0f9b-4531-87b3-6dfbb33b079e

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.

macroscopeapp[bot]
macroscopeappBot previously approved these changes Jul 17, 2026
@macroscopeapp

macroscopeappBot commented Jul 17, 2026

Copy link
Copy Markdown

Approvability

Verdict: 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.

@vercel
vercelBottemporarily deployed to Preview – clerk-js-sandbox July 20, 2026 21:20 Inactive
@alexcarpenter
alexcarpenterforce-pushed the carp/account-button-controller branch from ba22f92 to c9bdf23CompareJuly 30, 2026 18:38
@alexcarpenter
alexcarpenterforce-pushed the carp/account-button-integration branch from 22e6748 to 4f890d8CompareJuly 30, 2026 18:38
@alexcarpenteralexcarpenter changed the title test(ui): add AccountButton connected integration testtest(ui): add UserButton connected integration testJul 30, 2026
alexcarpenterand others added 28 commits August 25, 2026 11:32
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

Copy link
Copy Markdown
Member

Folded into #9185.

@EphemEphem mentioned this pull request Aug 25, 2026
9 tasks
Sign up for freeto 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.

3 participants

@alexcarpenter@kylemac@Ephem
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(ui): connect the Mosaic UserButton and its custom UserProfile pages - #9191

Closed
alexcarpenter wants to merge 30 commits into
carp/account-button-controllerfrom
carp/account-button-integration
Closed

feat(ui): connect the Mosaic UserButton and its custom UserProfile pages#9191
alexcarpenter wants to merge 30 commits into
carp/account-button-controllerfrom
carp/account-button-integration

Conversation

@alexcarpenter

@alexcarpenteralexcarpenter commented Jul 17, 2026

Copy link
Copy Markdown
Member

Description

Stacked on #9185. Covers the connected UserButton through 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.ts holds both instead of two useStates in the container. Re-entry, clearing busy, and closing on success stop being hand written there: 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.

The surface holds still while an action runs. The view renders the controller the action started from. setActive swaps 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.

mode reaches 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 whatever mode asked 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

userProfileProps puts your own pages and links into the UserProfile the button opens, and orders that profile's navigation.

<UserButtonuserProfileProps={{customPages: [{label: 'Terms',path: 'terms',icon: <FileIcon/>,content: <Terms/>},{label: 'Docs',path: 'docs',href: 'https://clerk.com/docs',icon: <BookIcon/>},],pageOrder: ['account','terms','security','docs'],}}/>

Plain objects rather than the <UserButton.UserProfilePage> children the pre-Mosaic button takes. Every entry has a path, which is what identifies it: with content it is a page inside the profile, with href it is a link out of it. icon is optional on both. pageOrder entries are ids: account, security, billing or apiKeys for a built-in page, or a custom entry's path.

Objects also drop the 'use client' requirement the children API carries. UserButton.UserProfilePage means 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, and content can 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 useCustomPages bridges the two: each entry goes out as the mount/unmount pair 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 UserButton and UserButtonProps and nothing else, so the hooks and the page types stay internal.

Two things worth a look in review:

  • The icon callbacks go out whether or not there is an icon. clerk-js decides what an item is from which callbacks are present and drops one missing an icon pair as invalid, so without them, leaving icon off would silently cost you the page.
  • clerk-js puts every built-in page it was not asked to move ahead of everything it was, so pageOrder has 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 what useUserProfilePages computes, behind the same shared guards clerk-js uses. It cannot see shouldShowBilling (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 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: test coverage

@vercel

vercelBot commented Jul 17, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreviewAug 25, 2026 11:02am
swingsetReadyReadyPreviewAug 25, 2026 11:02am

Request Review

@changeset-bot

changeset-botBot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 30adecc

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

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: 2a7f3fe3-0f9b-4531-87b3-6dfbb33b079e

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.

macroscopeapp[bot]
macroscopeappBot previously approved these changes Jul 17, 2026
@macroscopeapp

macroscopeappBot commented Jul 17, 2026

Copy link
Copy Markdown

Approvability

Verdict: 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.

@vercel
vercelBottemporarily deployed to Preview – clerk-js-sandbox July 20, 2026 21:20 Inactive
@alexcarpenter
alexcarpenterforce-pushed the carp/account-button-controller branch from ba22f92 to c9bdf23CompareJuly 30, 2026 18:38
@alexcarpenter
alexcarpenterforce-pushed the carp/account-button-integration branch from 22e6748 to 4f890d8CompareJuly 30, 2026 18:38
@alexcarpenteralexcarpenter changed the title test(ui): add AccountButton connected integration testtest(ui): add UserButton connected integration testJul 30, 2026
alexcarpenterand others added 28 commits August 25, 2026 11:32
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

Copy link
Copy Markdown
Member

Folded into #9185.

@EphemEphem mentioned this pull request Aug 25, 2026
9 tasks
Sign up for freeto 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.

3 participants

@alexcarpenter@kylemac@Ephem
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(ui): connect the Mosaic UserButton and its custom UserProfile pages - #9191

Closed
alexcarpenter wants to merge 30 commits into
carp/account-button-controllerfrom
carp/account-button-integration
Closed

feat(ui): connect the Mosaic UserButton and its custom UserProfile pages#9191
alexcarpenter wants to merge 30 commits into
carp/account-button-controllerfrom
carp/account-button-integration

Conversation

@alexcarpenter

@alexcarpenteralexcarpenter commented Jul 17, 2026

Copy link
Copy Markdown
Member

Description

Stacked on #9185. Covers the connected UserButton through 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.ts holds both instead of two useStates in the container. Re-entry, clearing busy, and closing on success stop being hand written there: 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.

The surface holds still while an action runs. The view renders the controller the action started from. setActive swaps 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.

mode reaches 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 whatever mode asked 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

userProfileProps puts your own pages and links into the UserProfile the button opens, and orders that profile's navigation.

<UserButtonuserProfileProps={{customPages: [{label: 'Terms',path: 'terms',icon: <FileIcon/>,content: <Terms/>},{label: 'Docs',path: 'docs',href: 'https://clerk.com/docs',icon: <BookIcon/>},],pageOrder: ['account','terms','security','docs'],}}/>

Plain objects rather than the <UserButton.UserProfilePage> children the pre-Mosaic button takes. Every entry has a path, which is what identifies it: with content it is a page inside the profile, with href it is a link out of it. icon is optional on both. pageOrder entries are ids: account, security, billing or apiKeys for a built-in page, or a custom entry's path.

Objects also drop the 'use client' requirement the children API carries. UserButton.UserProfilePage means 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, and content can 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 useCustomPages bridges the two: each entry goes out as the mount/unmount pair 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 UserButton and UserButtonProps and nothing else, so the hooks and the page types stay internal.

Two things worth a look in review:

  • The icon callbacks go out whether or not there is an icon. clerk-js decides what an item is from which callbacks are present and drops one missing an icon pair as invalid, so without them, leaving icon off would silently cost you the page.
  • clerk-js puts every built-in page it was not asked to move ahead of everything it was, so pageOrder has 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 what useUserProfilePages computes, behind the same shared guards clerk-js uses. It cannot see shouldShowBilling (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 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: test coverage

@vercel

vercelBot commented Jul 17, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreviewAug 25, 2026 11:02am
swingsetReadyReadyPreviewAug 25, 2026 11:02am

Request Review

@changeset-bot

changeset-botBot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 30adecc

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

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: 2a7f3fe3-0f9b-4531-87b3-6dfbb33b079e

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.

macroscopeapp[bot]
macroscopeappBot previously approved these changes Jul 17, 2026
@macroscopeapp

macroscopeappBot commented Jul 17, 2026

Copy link
Copy Markdown

Approvability

Verdict: 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.

@vercel
vercelBottemporarily deployed to Preview – clerk-js-sandbox July 20, 2026 21:20 Inactive
@alexcarpenter
alexcarpenterforce-pushed the carp/account-button-controller branch from ba22f92 to c9bdf23CompareJuly 30, 2026 18:38
@alexcarpenter
alexcarpenterforce-pushed the carp/account-button-integration branch from 22e6748 to 4f890d8CompareJuly 30, 2026 18:38
@alexcarpenteralexcarpenter changed the title test(ui): add AccountButton connected integration testtest(ui): add UserButton connected integration testJul 30, 2026
alexcarpenterand others added 28 commits August 25, 2026 11:32
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

Copy link
Copy Markdown
Member

Folded into #9185.

@EphemEphem mentioned this pull request Aug 25, 2026
9 tasks
Sign up for freeto 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.

3 participants

@alexcarpenter@kylemac@Ephem
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(ui): connect the Mosaic UserButton and its custom UserProfile pages - #9191

Closed
alexcarpenter wants to merge 30 commits into
carp/account-button-controllerfrom
carp/account-button-integration
Closed

feat(ui): connect the Mosaic UserButton and its custom UserProfile pages#9191
alexcarpenter wants to merge 30 commits into
carp/account-button-controllerfrom
carp/account-button-integration

Conversation

@alexcarpenter

@alexcarpenteralexcarpenter commented Jul 17, 2026

Copy link
Copy Markdown
Member

Description

Stacked on #9185. Covers the connected UserButton through 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.ts holds both instead of two useStates in the container. Re-entry, clearing busy, and closing on success stop being hand written there: 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.

The surface holds still while an action runs. The view renders the controller the action started from. setActive swaps 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.

mode reaches 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 whatever mode asked 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

userProfileProps puts your own pages and links into the UserProfile the button opens, and orders that profile's navigation.

<UserButtonuserProfileProps={{customPages: [{label: 'Terms',path: 'terms',icon: <FileIcon/>,content: <Terms/>},{label: 'Docs',path: 'docs',href: 'https://clerk.com/docs',icon: <BookIcon/>},],pageOrder: ['account','terms','security','docs'],}}/>

Plain objects rather than the <UserButton.UserProfilePage> children the pre-Mosaic button takes. Every entry has a path, which is what identifies it: with content it is a page inside the profile, with href it is a link out of it. icon is optional on both. pageOrder entries are ids: account, security, billing or apiKeys for a built-in page, or a custom entry's path.

Objects also drop the 'use client' requirement the children API carries. UserButton.UserProfilePage means 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, and content can 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 useCustomPages bridges the two: each entry goes out as the mount/unmount pair 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 UserButton and UserButtonProps and nothing else, so the hooks and the page types stay internal.

Two things worth a look in review:

  • The icon callbacks go out whether or not there is an icon. clerk-js decides what an item is from which callbacks are present and drops one missing an icon pair as invalid, so without them, leaving icon off would silently cost you the page.
  • clerk-js puts every built-in page it was not asked to move ahead of everything it was, so pageOrder has 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 what useUserProfilePages computes, behind the same shared guards clerk-js uses. It cannot see shouldShowBilling (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 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: test coverage

@vercel

vercelBot commented Jul 17, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreviewAug 25, 2026 11:02am
swingsetReadyReadyPreviewAug 25, 2026 11:02am

Request Review

@changeset-bot

changeset-botBot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 30adecc

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

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: 2a7f3fe3-0f9b-4531-87b3-6dfbb33b079e

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.

macroscopeapp[bot]
macroscopeappBot previously approved these changes Jul 17, 2026
@macroscopeapp

macroscopeappBot commented Jul 17, 2026

Copy link
Copy Markdown

Approvability

Verdict: 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.

@vercel
vercelBottemporarily deployed to Preview – clerk-js-sandbox July 20, 2026 21:20 Inactive
@alexcarpenter
alexcarpenterforce-pushed the carp/account-button-controller branch from ba22f92 to c9bdf23CompareJuly 30, 2026 18:38
@alexcarpenter
alexcarpenterforce-pushed the carp/account-button-integration branch from 22e6748 to 4f890d8CompareJuly 30, 2026 18:38
@alexcarpenteralexcarpenter changed the title test(ui): add AccountButton connected integration testtest(ui): add UserButton connected integration testJul 30, 2026
alexcarpenterand others added 28 commits August 25, 2026 11:32
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

Copy link
Copy Markdown
Member

Folded into #9185.

@EphemEphem mentioned this pull request Aug 25, 2026
9 tasks
Sign up for freeto 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.

3 participants

@alexcarpenter@kylemac@Ephem
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(ui): connect the Mosaic UserButton and its custom UserProfile pages - #9191

Closed
alexcarpenter wants to merge 30 commits into
carp/account-button-controllerfrom
carp/account-button-integration
Closed

feat(ui): connect the Mosaic UserButton and its custom UserProfile pages#9191
alexcarpenter wants to merge 30 commits into
carp/account-button-controllerfrom
carp/account-button-integration

Conversation

@alexcarpenter

@alexcarpenteralexcarpenter commented Jul 17, 2026

Copy link
Copy Markdown
Member

Description

Stacked on #9185. Covers the connected UserButton through 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.ts holds both instead of two useStates in the container. Re-entry, clearing busy, and closing on success stop being hand written there: 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.

The surface holds still while an action runs. The view renders the controller the action started from. setActive swaps 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.

mode reaches 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 whatever mode asked 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

userProfileProps puts your own pages and links into the UserProfile the button opens, and orders that profile's navigation.

<UserButtonuserProfileProps={{customPages: [{label: 'Terms',path: 'terms',icon: <FileIcon/>,content: <Terms/>},{label: 'Docs',path: 'docs',href: 'https://clerk.com/docs',icon: <BookIcon/>},],pageOrder: ['account','terms','security','docs'],}}/>

Plain objects rather than the <UserButton.UserProfilePage> children the pre-Mosaic button takes. Every entry has a path, which is what identifies it: with content it is a page inside the profile, with href it is a link out of it. icon is optional on both. pageOrder entries are ids: account, security, billing or apiKeys for a built-in page, or a custom entry's path.

Objects also drop the 'use client' requirement the children API carries. UserButton.UserProfilePage means 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, and content can 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 useCustomPages bridges the two: each entry goes out as the mount/unmount pair 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 UserButton and UserButtonProps and nothing else, so the hooks and the page types stay internal.

Two things worth a look in review:

  • The icon callbacks go out whether or not there is an icon. clerk-js decides what an item is from which callbacks are present and drops one missing an icon pair as invalid, so without them, leaving icon off would silently cost you the page.
  • clerk-js puts every built-in page it was not asked to move ahead of everything it was, so pageOrder has 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 what useUserProfilePages computes, behind the same shared guards clerk-js uses. It cannot see shouldShowBilling (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 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: test coverage

@vercel

vercelBot commented Jul 17, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreviewAug 25, 2026 11:02am
swingsetReadyReadyPreviewAug 25, 2026 11:02am

Request Review

@changeset-bot

changeset-botBot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 30adecc

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

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: 2a7f3fe3-0f9b-4531-87b3-6dfbb33b079e

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.

macroscopeapp[bot]
macroscopeappBot previously approved these changes Jul 17, 2026
@macroscopeapp

macroscopeappBot commented Jul 17, 2026

Copy link
Copy Markdown

Approvability

Verdict: 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.

@vercel
vercelBottemporarily deployed to Preview – clerk-js-sandbox July 20, 2026 21:20 Inactive
@alexcarpenter
alexcarpenterforce-pushed the carp/account-button-controller branch from ba22f92 to c9bdf23CompareJuly 30, 2026 18:38
@alexcarpenter
alexcarpenterforce-pushed the carp/account-button-integration branch from 22e6748 to 4f890d8CompareJuly 30, 2026 18:38
@alexcarpenteralexcarpenter changed the title test(ui): add AccountButton connected integration testtest(ui): add UserButton connected integration testJul 30, 2026
alexcarpenterand others added 28 commits August 25, 2026 11:32
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

Copy link
Copy Markdown
Member

Folded into #9185.

@EphemEphem mentioned this pull request Aug 25, 2026
9 tasks
Sign up for freeto 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.

3 participants

@alexcarpenter@kylemac@Ephem
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(ui): connect the Mosaic UserButton and its custom UserProfile pages - #9191

Closed
alexcarpenter wants to merge 30 commits into
carp/account-button-controllerfrom
carp/account-button-integration
Closed

feat(ui): connect the Mosaic UserButton and its custom UserProfile pages#9191
alexcarpenter wants to merge 30 commits into
carp/account-button-controllerfrom
carp/account-button-integration

Conversation

@alexcarpenter

@alexcarpenteralexcarpenter commented Jul 17, 2026

Copy link
Copy Markdown
Member

Description

Stacked on #9185. Covers the connected UserButton through 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.ts holds both instead of two useStates in the container. Re-entry, clearing busy, and closing on success stop being hand written there: 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.

The surface holds still while an action runs. The view renders the controller the action started from. setActive swaps 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.

mode reaches 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 whatever mode asked 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

userProfileProps puts your own pages and links into the UserProfile the button opens, and orders that profile's navigation.

<UserButtonuserProfileProps={{customPages: [{label: 'Terms',path: 'terms',icon: <FileIcon/>,content: <Terms/>},{label: 'Docs',path: 'docs',href: 'https://clerk.com/docs',icon: <BookIcon/>},],pageOrder: ['account','terms','security','docs'],}}/>

Plain objects rather than the <UserButton.UserProfilePage> children the pre-Mosaic button takes. Every entry has a path, which is what identifies it: with content it is a page inside the profile, with href it is a link out of it. icon is optional on both. pageOrder entries are ids: account, security, billing or apiKeys for a built-in page, or a custom entry's path.

Objects also drop the 'use client' requirement the children API carries. UserButton.UserProfilePage means 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, and content can 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 useCustomPages bridges the two: each entry goes out as the mount/unmount pair 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 UserButton and UserButtonProps and nothing else, so the hooks and the page types stay internal.

Two things worth a look in review:

  • The icon callbacks go out whether or not there is an icon. clerk-js decides what an item is from which callbacks are present and drops one missing an icon pair as invalid, so without them, leaving icon off would silently cost you the page.
  • clerk-js puts every built-in page it was not asked to move ahead of everything it was, so pageOrder has 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 what useUserProfilePages computes, behind the same shared guards clerk-js uses. It cannot see shouldShowBilling (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 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: test coverage

@vercel

vercelBot commented Jul 17, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreviewAug 25, 2026 11:02am
swingsetReadyReadyPreviewAug 25, 2026 11:02am

Request Review

@changeset-bot

changeset-botBot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 30adecc

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

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: 2a7f3fe3-0f9b-4531-87b3-6dfbb33b079e

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.

macroscopeapp[bot]
macroscopeappBot previously approved these changes Jul 17, 2026
@macroscopeapp

macroscopeappBot commented Jul 17, 2026

Copy link
Copy Markdown

Approvability

Verdict: 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.

@vercel
vercelBottemporarily deployed to Preview – clerk-js-sandbox July 20, 2026 21:20 Inactive
@alexcarpenter
alexcarpenterforce-pushed the carp/account-button-controller branch from ba22f92 to c9bdf23CompareJuly 30, 2026 18:38
@alexcarpenter
alexcarpenterforce-pushed the carp/account-button-integration branch from 22e6748 to 4f890d8CompareJuly 30, 2026 18:38
@alexcarpenteralexcarpenter changed the title test(ui): add AccountButton connected integration testtest(ui): add UserButton connected integration testJul 30, 2026
alexcarpenterand others added 28 commits August 25, 2026 11:32
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

Copy link
Copy Markdown
Member

Folded into #9185.

@EphemEphem mentioned this pull request Aug 25, 2026
9 tasks
Sign up for freeto 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.

3 participants

@alexcarpenter@kylemac@Ephem
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(ui): connect the Mosaic UserButton and its custom UserProfile pages - #9191

Closed
alexcarpenter wants to merge 30 commits into
carp/account-button-controllerfrom
carp/account-button-integration
Closed

feat(ui): connect the Mosaic UserButton and its custom UserProfile pages#9191
alexcarpenter wants to merge 30 commits into
carp/account-button-controllerfrom
carp/account-button-integration

Conversation

@alexcarpenter

@alexcarpenteralexcarpenter commented Jul 17, 2026

Copy link
Copy Markdown
Member

Description

Stacked on #9185. Covers the connected UserButton through 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.ts holds both instead of two useStates in the container. Re-entry, clearing busy, and closing on success stop being hand written there: 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.

The surface holds still while an action runs. The view renders the controller the action started from. setActive swaps 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.

mode reaches 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 whatever mode asked 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

userProfileProps puts your own pages and links into the UserProfile the button opens, and orders that profile's navigation.

<UserButtonuserProfileProps={{customPages: [{label: 'Terms',path: 'terms',icon: <FileIcon/>,content: <Terms/>},{label: 'Docs',path: 'docs',href: 'https://clerk.com/docs',icon: <BookIcon/>},],pageOrder: ['account','terms','security','docs'],}}/>

Plain objects rather than the <UserButton.UserProfilePage> children the pre-Mosaic button takes. Every entry has a path, which is what identifies it: with content it is a page inside the profile, with href it is a link out of it. icon is optional on both. pageOrder entries are ids: account, security, billing or apiKeys for a built-in page, or a custom entry's path.

Objects also drop the 'use client' requirement the children API carries. UserButton.UserProfilePage means 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, and content can 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 useCustomPages bridges the two: each entry goes out as the mount/unmount pair 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 UserButton and UserButtonProps and nothing else, so the hooks and the page types stay internal.

Two things worth a look in review:

  • The icon callbacks go out whether or not there is an icon. clerk-js decides what an item is from which callbacks are present and drops one missing an icon pair as invalid, so without them, leaving icon off would silently cost you the page.
  • clerk-js puts every built-in page it was not asked to move ahead of everything it was, so pageOrder has 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 what useUserProfilePages computes, behind the same shared guards clerk-js uses. It cannot see shouldShowBilling (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 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: test coverage

@vercel

vercelBot commented Jul 17, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreviewAug 25, 2026 11:02am
swingsetReadyReadyPreviewAug 25, 2026 11:02am

Request Review

@changeset-bot

changeset-botBot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 30adecc

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

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: 2a7f3fe3-0f9b-4531-87b3-6dfbb33b079e

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.

macroscopeapp[bot]
macroscopeappBot previously approved these changes Jul 17, 2026
@macroscopeapp

macroscopeappBot commented Jul 17, 2026

Copy link
Copy Markdown

Approvability

Verdict: 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.

@vercel
vercelBottemporarily deployed to Preview – clerk-js-sandbox July 20, 2026 21:20 Inactive
@alexcarpenter
alexcarpenterforce-pushed the carp/account-button-controller branch from ba22f92 to c9bdf23CompareJuly 30, 2026 18:38
@alexcarpenter
alexcarpenterforce-pushed the carp/account-button-integration branch from 22e6748 to 4f890d8CompareJuly 30, 2026 18:38
@alexcarpenteralexcarpenter changed the title test(ui): add AccountButton connected integration testtest(ui): add UserButton connected integration testJul 30, 2026
alexcarpenterand others added 28 commits August 25, 2026 11:32
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

Copy link
Copy Markdown
Member

Folded into #9185.

@EphemEphem mentioned this pull request Aug 25, 2026
9 tasks
Sign up for freeto 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.

3 participants

@alexcarpenter@kylemac@Ephem
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(ui): connect the Mosaic UserButton and its custom UserProfile pages - #9191

Closed
alexcarpenter wants to merge 30 commits into
carp/account-button-controllerfrom
carp/account-button-integration
Closed

feat(ui): connect the Mosaic UserButton and its custom UserProfile pages#9191
alexcarpenter wants to merge 30 commits into
carp/account-button-controllerfrom
carp/account-button-integration

Conversation

@alexcarpenter

@alexcarpenteralexcarpenter commented Jul 17, 2026

Copy link
Copy Markdown
Member

Description

Stacked on #9185. Covers the connected UserButton through 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.ts holds both instead of two useStates in the container. Re-entry, clearing busy, and closing on success stop being hand written there: 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.

The surface holds still while an action runs. The view renders the controller the action started from. setActive swaps 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.

mode reaches 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 whatever mode asked 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

userProfileProps puts your own pages and links into the UserProfile the button opens, and orders that profile's navigation.

<UserButtonuserProfileProps={{customPages: [{label: 'Terms',path: 'terms',icon: <FileIcon/>,content: <Terms/>},{label: 'Docs',path: 'docs',href: 'https://clerk.com/docs',icon: <BookIcon/>},],pageOrder: ['account','terms','security','docs'],}}/>

Plain objects rather than the <UserButton.UserProfilePage> children the pre-Mosaic button takes. Every entry has a path, which is what identifies it: with content it is a page inside the profile, with href it is a link out of it. icon is optional on both. pageOrder entries are ids: account, security, billing or apiKeys for a built-in page, or a custom entry's path.

Objects also drop the 'use client' requirement the children API carries. UserButton.UserProfilePage means 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, and content can 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 useCustomPages bridges the two: each entry goes out as the mount/unmount pair 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 UserButton and UserButtonProps and nothing else, so the hooks and the page types stay internal.

Two things worth a look in review:

  • The icon callbacks go out whether or not there is an icon. clerk-js decides what an item is from which callbacks are present and drops one missing an icon pair as invalid, so without them, leaving icon off would silently cost you the page.
  • clerk-js puts every built-in page it was not asked to move ahead of everything it was, so pageOrder has 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 what useUserProfilePages computes, behind the same shared guards clerk-js uses. It cannot see shouldShowBilling (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 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: test coverage

@vercel

vercelBot commented Jul 17, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreviewAug 25, 2026 11:02am
swingsetReadyReadyPreviewAug 25, 2026 11:02am

Request Review

@changeset-bot

changeset-botBot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 30adecc

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

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: 2a7f3fe3-0f9b-4531-87b3-6dfbb33b079e

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.

macroscopeapp[bot]
macroscopeappBot previously approved these changes Jul 17, 2026
@macroscopeapp

macroscopeappBot commented Jul 17, 2026

Copy link
Copy Markdown

Approvability

Verdict: 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.

@vercel
vercelBottemporarily deployed to Preview – clerk-js-sandbox July 20, 2026 21:20 Inactive
@alexcarpenter
alexcarpenterforce-pushed the carp/account-button-controller branch from ba22f92 to c9bdf23CompareJuly 30, 2026 18:38
@alexcarpenter
alexcarpenterforce-pushed the carp/account-button-integration branch from 22e6748 to 4f890d8CompareJuly 30, 2026 18:38
@alexcarpenteralexcarpenter changed the title test(ui): add AccountButton connected integration testtest(ui): add UserButton connected integration testJul 30, 2026
alexcarpenterand others added 28 commits August 25, 2026 11:32
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

Copy link
Copy Markdown
Member

Folded into #9185.

@EphemEphem mentioned this pull request Aug 25, 2026
9 tasks
Sign up for freeto 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.

3 participants

@alexcarpenter@kylemac@Ephem