Skip to content

feat(ui): Expose profile sub components#8654

Open
alexcarpenter wants to merge 60 commits into
mainfrom
carp/profile-section-components
Open

feat(ui): Expose profile sub components#8654
alexcarpenter wants to merge 60 commits into
mainfrom
carp/profile-section-components

Conversation

@alexcarpenter

@alexcarpenter alexcarpenter commented May 26, 2026

Copy link
Copy Markdown
Member

Summary

Ships a composable UserProfile / OrganizationProfile from @clerk/ui/experimental. Consumers render individual panels and sections (Account, Security, Members, Billing, …) inline in their own app instead of inside Clerk's modal/page flow.

The API is a set of flat, top-level named exports — not a namespace object like UserProfile.Account. Each named export of a 'use client' module becomes its own React Server Component client reference, so consumers can render these directly in a Server Component tree without adding their own 'use client' boundary. (Property access on a namespace object would not survive the RSC boundary.)

import {
  UserProfileProvider,
  UserProfileAccountPanel,
  UserProfileSecurityPanel,
  UserProfileEmailSection,
  UserProfilePasswordSection,
} from '@clerk/ui/experimental';

export default function Page() {
  return (
    <UserProfileProvider>
      {/* A panel with no children renders the full standard page… */}
      <UserProfileAccountPanel />
      {/* …or you compose the sub-sections yourself. */}
      <UserProfileSecurityPanel>
        <UserProfilePasswordSection />
      </UserProfileSecurityPanel>
    </UserProfileProvider>
  );
}

Design decisions worth scrutiny

Area Decision Why
Flat named exports Every panel/section is a top-level named export from @clerk/ui/experimental, not a property on a namespace object. flat-exports.test.ts pins the surface and asserts the namespace objects are undefined. RSC client references (see Summary).
Provider shell (ProfileProviderShell.tsx) Collapse LazyProviders + LazyComponentRenderer / LazyModalRenderer (portal path) into one shell. ClerkContextProvider omitted. Composed mounts bypass the portal. Consumer's <ClerkProvider> already supplies clerk via useClerk().
Emotion cache Shared per clerk instance via WeakMap (internal/styleCacheStore.ts). Sibling composed roots would otherwise create two cl-internal caches and double-insert rules. Keyed on the clerk instance object, so it auto-GCs on teardown.
moduleManager clerk-js exposes __internal_moduleManager getter on Clerk (clerk.ts:288); IsomorphicClerk forwards it (isomorphicClerk.ts:298). Composed shell reads clerk.__internal_moduleManager directly. Makes <ClerkProvider ui={ui} /> optional for composed (Web3 works out of box). Plain property access is the only channel that crosses the bundle boundary — clerk-js loads standalone with its own inlined @clerk/shared, so @clerk/react/@clerk/ui reading @clerk/shared from node_modules see a different copy.
Options threading Pull localization + supportEmail + nonce from clerk.__internal_getOption, pass via OptionsProvider / SharedStyleCacheProvider. Only keys reachable from the composed subtree (makeLocalizable, parseLocalization, useSupportEmail, Emotion CSP nonce). nonce needs an as any cast (typed on IsomorphicClerkOptions, not ClerkOptions).
cssLayerName Apply extractCssLayerNameFromAppearance on globalAppearance before reading. Matches portal path normalization (Components.tsx:209). Without it, nested appearance.theme.cssLayerName is ignored.
Stub router (stubRouter.ts) navigate / baseNavigate / resolve are real and SSR-safe. currentPath is a static empty string (no popstate listener). matches() / refresh() / getMatchData() throw in dev, no-op in prod. Composed sections render outside the AIO Route/Switch tree. Dev throw catches accidental AIO API use, prod no-op keeps consumer apps from crashing on the same call. Contracts pinned by __tests__/stub-limitations.test.ts.
Dual-mode panels A panel with no children passes through to the full AIO page (e.g. UserProfileAccountPanelAccountPage). With children, sub-sections render directly under PageContext + CardStateProvider. Lets consumers either drop in a section as-is or compose sub-parts. Passthrough only breaks if a sub-component calls matches()/refresh()/getMatchData() (dev throw, prod no-op).
Security panel OrganizationProfileSecurityPanel renders the whole standard OrganizationSecurityPage (SSO overview + configuration wizard). It takes no composable children — the security tab is not section-based, unlike General. There is no standalone ConfigureSSO composable. Mirrors what <OrganizationProfile /> shows on its security route.
Section dedupe createSection(name, Component) factory + PageContext guard via useRequirePage. Each leaf is a one-liner instead of repeating wrapper boilerplate.

Suggested review order

  1. packages/ui/src/composed/ProfileProviderShell.tsx: the architectural heart.
  2. packages/clerk-js/src/core/clerk.ts (__internal_moduleManager getter, clerk.ts:288) + packages/react/src/isomorphicClerk.ts (forwards to the wrapper, :298): confirm the cross-bundle wiring and the ABI tradeoff.
  3. packages/ui/src/composed/stubRouter.ts + __tests__/stub-limitations.test.ts: router contract and dev/prod asymmetry.
  4. packages/ui/src/composed/{UserProfile,OrganizationProfile}/*Provider.tsx: section wiring + __internal_environment cast.
  5. packages/ui/src/composed/__tests__/: parity, wiring, style-cache, flat-export, and tree-shaking tests pin the design.
  6. integration/tests/composed-components.test.ts: e2e parity — the composed exports run the same trusted sign-in / profile flows as the standard components against a real clerk-js runtime.

__internal_moduleManager ABI tradeoff

New getter on clerk-js's Clerk class. Once shipped, removing it breaks every pinned SDK version that reads it.

Why accept the cost:

  • IsomorphicClerk already proxies every other piece of clerk-js state through public-ish properties. One more is consistent.
  • __internal_ prefix signals "may change between majors" — same contract as the rest of __internal_* on Clerk.
  • Only channel that reliably crosses the bundle boundary.

Alternatives considered and rejected:

  • @clerk/shared WeakMap registry (a prior design, now deleted): doesn't cross the bundle boundary. clerk-js inlines its own @clerk/shared, so the WeakMap clerk-js writes isn't the one @clerk/react/@clerk/ui read. Under version skew / no-dedupe the registry splits and dynamic-import features silently fall back to the rejecting manager.
  • Symbol.for(...)-keyed globalThis WeakMap: crosses bundles, but hides the contract in an unnamed channel. Action-at-a-distance, lifecycle issues with test workers and HMR, still on the hook to keep the symbol stable forever — same cost, less inspectable.

Resolves PD-56

Test plan

  • CI green
  • Smoke test in a Next.js sandbox app without <ClerkProvider ui={ui} />: confirm Web3 Add-Coinbase flow resolves the SDK
  • Verify a UserProfileProvider and an OrganizationProfileProvider sibling-mounted on one page: single cl-internal cache, localized strings render
  • e2e parity in integration/tests/composed-components.test.ts: the composed UserProfile / OrganizationProfile exports pass the same trusted flows as the standard components (account edits, security, org general/members/security)

@changeset-bot

changeset-bot Bot commented May 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2389631

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

This PR includes changesets to release 23 packages
Name Type
@clerk/ui Minor
@clerk/clerk-js Patch
@clerk/shared Patch
@clerk/react Patch
@clerk/astro Patch
@clerk/chrome-extension Patch
@clerk/swingset Patch
@clerk/vue Patch
@clerk/electron Patch
@clerk/expo Patch
@clerk/backend Patch
@clerk/expo-passkeys Patch
@clerk/express Patch
@clerk/fastify Patch
@clerk/headless Patch
@clerk/hono Patch
@clerk/localizations Patch
@clerk/msw Patch
@clerk/nextjs Patch
@clerk/nuxt Patch
@clerk/react-router Patch
@clerk/tanstack-react-start Patch
@clerk/testing Patch

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

@vercel

vercel Bot commented May 26, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Jul 7, 2026 8:51pm
swingset Ready Ready Preview, Comment Jul 7, 2026 8:51pm

Request Review

@alexcarpenter

Copy link
Copy Markdown
Member Author

!snapshot

@github-actions

This comment has been minimized.

@alexcarpenter

Copy link
Copy Markdown
Member Author

!snapshot

@github-actions

This comment has been minimized.

@alexcarpenter

Copy link
Copy Markdown
Member Author

!snapshot

@github-actions

This comment has been minimized.

@alexcarpenter

Copy link
Copy Markdown
Member Author

!snapshot

@github-actions

This comment has been minimized.

@alexcarpenter

Copy link
Copy Markdown
Member Author

!snapshot

@github-actions

This comment has been minimized.

…tion factory

- Extract createSection factory to eliminate repeated useRequirePage guard pattern across 18 section files (9→4 lines each)
- Extract shared BillingSection and APIKeysSection to deduplicate identical components between UserProfile and OrganizationProfile
- Memoize useBillingRouter to prevent unnecessary RouteContext consumer re-renders
- Change useRequirePage from console.warn to throw for consistent error behavior with other context guards
- Remove dead additionalOAuthScopes prop from OrganizationProfileProvider
- Add tree-shaking regression tests to enforce per-file module isolation
…k/shared

Sibling-mounted composed UserProfile/OrganizationProfile share one emotion
cache per Clerk instance, thread localization/supportEmail through, hoist
nested appearance.theme.cssLayerName for @layer wrapping, and track the
consumer URL via popstate. ProfileProviderShell collapses LazyProviders +
LazyComponentRenderer (portal path) for composed mounts.

Moves the moduleManager WeakMap to @clerk/shared/moduleManager and has the
Clerk constructor register its internal ModuleManager so @clerk/ui composed
components resolve it without requiring <ClerkProvider ui={ui} />. No
clerk-js public ABI added.
- stubRouter.matches/refresh/getMatchData throw in dev to catch
  accidental <Route>/refresh()/getMatchData() use inside composed sections
- drop popstate-driven currentPath snapshot from ProfileProviderShell
  (never fired at the right time; section unmount already clears
  per-section CardStateProvider)
- document useBillingRouter as intentionally in-memory (back/forward,
  refresh, deep-links do not preserve sub-route or tab state)
- drop unnecessary __internal_getOption as-any casts; keep nonce cast
  (nonce lives on IsomorphicClerkOptions, not ClerkOptions)
- AppearanceOverrides: destructure parsedElements instead of slice math
- useBillingRouter: drop dead _currentRoute parameter
- tests pinning the decisions above
Mock at line 100 extracted the method without 'this', so falling through
to the real implementation crashed accessing this.#options. Surfaced
after composed code stopped masking the call site with as-any casts.
…rapper

clerk-js's Clerk constructor registers its ModuleManager keyed against
the inner Clerk instance. Composed/subcomponent UserProfile reads it via
getModuleManager(useClerk()), which returns the IsomorphicClerk wrapper —
a different object identity, so the WeakMap lookup misses and the
provider falls back to a no-op import shim. Dynamic-imported features
(Coinbase Wallet, Base, zxcvbn, Stripe) silently break; e.g. the Coinbase
Wallet flow POSTs an empty web3_wallet and the user sees a 422
form_param_nil.

Copy the manager onto the wrapper's key when replayInterceptedInvocations
runs, so reads via useClerk() resolve correctly.

Also guard Web3Form against empty identifiers locally so any future
missing-provider scenario surfaces as a clear UI message rather than a
mystifying server-side 422.
Replace the namespace re-export pattern (parts.ts + `export * as`) with
explicit compound objects assembled in index.tsx, so property-access
tree-shaking on `UserProfile.X` / `OrganizationProfile.X` can drop unused
leaves under `sideEffects: false`. Push the 'use client' directive down
to each leaf component instead of marking the whole barrel client — the
barrel itself contains no React code now, so server components can
import the namespace and have unused server-safe siblings tree-shaken
out. Update the tree-shaking invariant test to check index.tsx for the
new shape (no `export *`, leaves don't import siblings).
…e handoff

clerk-js loads standalone from the CDN with its own inlined
@clerk/shared, so the module-scoped WeakMap in
@clerk/shared/moduleManager is invisible to consumers that import
@clerk/shared from node_modules (every framework SDK). The
WeakMap-only registration in clerk-js's constructor never reached
IsomorphicClerk or @clerk/ui's composed UserProfile, so dynamic-
imported features (Coinbase Wallet, Base, Stripe, zxcvbn) silently
fell back to a no-op manager.

Restore the `__internal_moduleManager` getter on the Clerk class
(originally added in 82399ea, removed in 81913dc). IsomorphicClerk
forwards through the getter on load, and writes the manager into the
node_modules @clerk/shared WeakMap so composed UserProfile's
`getModuleManager(useClerk())` lookup resolves correctly. WeakMap
registration in clerk-js stays as a same-bundle fallback.

Tradeoff acknowledged: `__internal_moduleManager` is an ABI surface on
Clerk, but it's the right channel — IsomorphicClerk proxies every
other piece of clerk-js state through public-ish properties, this is
just one more. The earlier refactor that hid the channel inside a
WeakMap made the contract uninspectable without removing it.
The Clerk interface now requires `__internal_moduleManager` (added so
framework SDK wrappers can forward clerk-js's ModuleManager across the
bundle boundary). IsomorphicClerk implements that interface but only
held the manager via the @clerk/shared WeakMap channel — TypeScript's
type-check rightly flagged the missing property.

Add a proxy getter that reads from `this.clerkjs?.__internal_moduleManager`
so the wrapper exposes the same surface as the inner Clerk. Composed
UI components continue to read through the WeakMap channel populated in
replayInterceptedInvocations; the getter is just there to satisfy the
interface and give direct access for code that has the wrapper.
Inline `import('../moduleManager').ModuleManager` violated
@typescript-eslint/consistent-type-imports. Hoist to a top-level
`import type` instead.
Replace the UserProfile/OrganizationProfile namespace objects with flat named exports (UserProfile*Panel/*Section, OrganizationProfile*Panel/*Section) so each component is a first-class client reference and renders in a React Server Component tree without a consumer 'use client' boundary.
Add an integration suite that drives the experimental @clerk/ui/experimental
composed UserProfile and OrganizationProfile exports through the shared
@clerk/testing page-object flows (username, name, email/phone with OTP, MFA,
account deletion; org render, rename, delete) with backend assertions.

Fix OrganizationProfileGeneralPanel crashing with 'CardState not found' when a
leave/delete section opens: wrap children in CardStateProvider, matching the
composed UserProfile account/security panels.
Replace the standalone OrganizationProfileConfigureSSOPanel composed export
with OrganizationProfileSecurityPanel, which renders the full standard
OrganizationSecurityPage (SSO overview + configuration wizard) so the composed
API can reproduce the OrganizationProfile security tab. The security tab has no
composable sub-sections, so the panel takes no children.

Add e2e parity coverage that renders the composed security panel and asserts it
shows the same security page the standard component renders on its security
route (Security header, SSO overview, Start configuration).
- Add return types + JSDoc to exported OrganizationGeneralPage sections
- Cover the enabled branch of AccountEnterpriseAccounts
- Fail fast in fallbackModuleManager instead of silently resolving undefined
@alexcarpenter

Copy link
Copy Markdown
Member Author

!snapshot

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Hey @alexcarpenter - the snapshot version command generated the following package versions:

Package Version
@clerk/astro 3.4.13-snapshot.v20260707124635
@clerk/backend 3.11.1-snapshot.v20260707124635
@clerk/chrome-extension 3.1.49-snapshot.v20260707124635
@clerk/clerk-js 6.25.0-snapshot.v20260707124635
@clerk/electron 0.0.10-snapshot.v20260707124635
@clerk/electron-passkeys 0.0.4-snapshot.v20260707124635
@clerk/eslint-plugin 0.2.1-snapshot.v20260707124635
@clerk/expo 3.7.1-snapshot.v20260707124635
@clerk/expo-passkeys 1.2.1-snapshot.v20260707124635
@clerk/express 2.1.37-snapshot.v20260707124635
@clerk/fastify 3.1.47-snapshot.v20260707124635
@clerk/headless 0.0.8-snapshot.v20260707124635
@clerk/hono 0.1.47-snapshot.v20260707124635
@clerk/localizations 4.13.0-snapshot.v20260707124635
@clerk/msw 0.0.44-snapshot.v20260707124635
@clerk/nextjs 7.5.14-snapshot.v20260707124635
@clerk/nuxt 2.6.13-snapshot.v20260707124635
@clerk/react 6.12.0-snapshot.v20260707124635
@clerk/react-router 3.5.6-snapshot.v20260707124635
@clerk/shared 4.25.0-snapshot.v20260707124635
@clerk/swingset 0.0.15-snapshot.v20260707124635
@clerk/tanstack-react-start 1.4.14-snapshot.v20260707124635
@clerk/testing 2.2.4-snapshot.v20260707124635
@clerk/ui 1.25.0-snapshot.v20260707124635
@clerk/upgrade 2.0.5-snapshot.v20260707124635
@clerk/vue 2.4.12-snapshot.v20260707124635

Tip: Use the snippet copy button below to quickly install the required packages.
@clerk/astro

npm i @clerk/astro@3.4.13-snapshot.v20260707124635 --save-exact

@clerk/backend

npm i @clerk/backend@3.11.1-snapshot.v20260707124635 --save-exact

@clerk/chrome-extension

npm i @clerk/chrome-extension@3.1.49-snapshot.v20260707124635 --save-exact

@clerk/clerk-js

npm i @clerk/clerk-js@6.25.0-snapshot.v20260707124635 --save-exact

@clerk/electron

npm i @clerk/electron@0.0.10-snapshot.v20260707124635 --save-exact

@clerk/electron-passkeys

npm i @clerk/electron-passkeys@0.0.4-snapshot.v20260707124635 --save-exact

@clerk/eslint-plugin

npm i @clerk/eslint-plugin@0.2.1-snapshot.v20260707124635 --save-exact

@clerk/expo

npm i @clerk/expo@3.7.1-snapshot.v20260707124635 --save-exact

@clerk/expo-passkeys

npm i @clerk/expo-passkeys@1.2.1-snapshot.v20260707124635 --save-exact

@clerk/express

npm i @clerk/express@2.1.37-snapshot.v20260707124635 --save-exact

@clerk/fastify

npm i @clerk/fastify@3.1.47-snapshot.v20260707124635 --save-exact

@clerk/headless

npm i @clerk/headless@0.0.8-snapshot.v20260707124635 --save-exact

@clerk/hono

npm i @clerk/hono@0.1.47-snapshot.v20260707124635 --save-exact

@clerk/localizations

npm i @clerk/localizations@4.13.0-snapshot.v20260707124635 --save-exact

@clerk/msw

npm i @clerk/msw@0.0.44-snapshot.v20260707124635 --save-exact

@clerk/nextjs

npm i @clerk/nextjs@7.5.14-snapshot.v20260707124635 --save-exact

@clerk/nuxt

npm i @clerk/nuxt@2.6.13-snapshot.v20260707124635 --save-exact

@clerk/react

npm i @clerk/react@6.12.0-snapshot.v20260707124635 --save-exact

@clerk/react-router

npm i @clerk/react-router@3.5.6-snapshot.v20260707124635 --save-exact

@clerk/shared

npm i @clerk/shared@4.25.0-snapshot.v20260707124635 --save-exact

@clerk/swingset

npm i @clerk/swingset@0.0.15-snapshot.v20260707124635 --save-exact

@clerk/tanstack-react-start

npm i @clerk/tanstack-react-start@1.4.14-snapshot.v20260707124635 --save-exact

@clerk/testing

npm i @clerk/testing@2.2.4-snapshot.v20260707124635 --save-exact

@clerk/ui

npm i @clerk/ui@1.25.0-snapshot.v20260707124635 --save-exact

@clerk/upgrade

npm i @clerk/upgrade@2.0.5-snapshot.v20260707124635 --save-exact

@clerk/vue

npm i @clerk/vue@2.4.12-snapshot.v20260707124635 --save-exact

…ed registry

Composed UserProfile/OrganizationProfile resolved the ModuleManager through a
@clerk/shared module-scoped WeakMap, which only works when the read and write
sides share the same physical @clerk/shared copy. Under version skew or
no-dedupe that registry splits and every dynamic-import feature silently falls
back to the rejecting manager. Read clerk.__internal_moduleManager directly
(plain cross-bundle property access) and delete the WeakMap plumbing.
Rename the ProfileCard.PageSection element to PagePanel across all call sites, and fix an incorrect oxlint-disable directive (repo uses eslint).
The assertion (connect yields a non-empty identifier) requires a real moduleManager performing the wallet-SDK dynamic import, which jsdom can't provide. Belongs in e2e, not a permanently-skipped unit test.
…ests

Remove the useEffect cleanup from useSafeAutoAnimate. Under React 18
StrictMode, effects double-invoke (setup/cleanup/setup) while callback
refs fire once, so the cleanup destroyed the auto-animate controller
during the simulated unmount and the ref never recreated it, leaving
no MutationObserver and no entrance animation in dev. Teardown is now
handled solely by the callback ref's null-branch.

Also trims redundant/library-characterization tests in the composed
profile suite (delete context-parity + tree-shaking, de-dupe the
moduleManager getter test, assert the visibility matrix once, collapse
action-animation, retarget the StrictMode test at the production
Animated component, trim emotion-internals cssLayerName tests).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants