diff --git a/.changeset/profile-section-components.md b/.changeset/profile-section-components.md
new file mode 100644
index 00000000000..de5db077940
--- /dev/null
+++ b/.changeset/profile-section-components.md
@@ -0,0 +1,5 @@
+---
+'@clerk/ui': minor
+---
+
+Expose composable `UserProfile` and `OrganizationProfile` subcomponents from `@clerk/ui/experimental`
diff --git a/.changeset/shared-moduleManager-registry.md b/.changeset/shared-moduleManager-registry.md
new file mode 100644
index 00000000000..a4ea2f1843e
--- /dev/null
+++ b/.changeset/shared-moduleManager-registry.md
@@ -0,0 +1,7 @@
+---
+'@clerk/clerk-js': patch
+'@clerk/shared': patch
+'@clerk/react': patch
+---
+
+Internal plumbing to support `@clerk/ui` composed `UserProfile` / `OrganizationProfile` subcomponents.
diff --git a/integration/tests/composed-components.test.ts b/integration/tests/composed-components.test.ts
new file mode 100644
index 00000000000..d75b6928e04
--- /dev/null
+++ b/integration/tests/composed-components.test.ts
@@ -0,0 +1,456 @@
+import { type BrowserContext, expect, type Page, test } from '@playwright/test';
+
+import type { Application } from '../models/application';
+import { appConfigs } from '../presets';
+import { PKGLAB } from '../presets/utils';
+import type { FakeOrganization, FakeUser } from '../testUtils';
+import { createTestUtils } from '../testUtils';
+import { stringPhoneNumber } from '../testUtils/phoneUtils';
+
+/**
+ * E2E parity coverage for the experimental composed exports from `@clerk/ui/experimental`.
+ *
+ * The composed API (`UserProfileProvider`, `UserProfileAccountPanel`, `UserProfileEmailSection`,
+ * ...) lets a developer compose the profile UI from flat exports. Its section wrappers render the
+ * exact same underlying components as the standard `` (e.g. `UserProfileEmailSection`
+ * -> `AccountEmails` from `AccountSections`), so the trusted `user-profile.test.ts` flows apply
+ * unchanged. This suite reuses the shared `@clerk/testing` page-object step helpers (`u.po.userProfile.*`)
+ * and the same assertions as `user-profile.test.ts`, so if a composed export diverges from the
+ * standard behavior an existing, trusted assertion fails.
+ *
+ * `user-profile.test.ts` is intentionally left untouched while this API is experimental. The only
+ * differences handled locally here are the composed component's lack of chrome:
+ * - it does not render the `.cl-userProfile-root` wrapper, so the mount wait keys off a rendered
+ * affordance instead;
+ * - it has no Account/Security tabs — the page renders both panels, so security sections are
+ * already present (no `switchToSecurityTab()`).
+ */
+
+// A page composed from the experimental section exports, mirroring the sections the standard
+// shows across its Account and Security tabs.
+const composedUserProfilePage = () => `'use client';
+import {
+ UserProfileProvider,
+ UserProfileAccountPanel,
+ UserProfileProfileSection,
+ UserProfileUsernameSection,
+ UserProfileEmailSection,
+ UserProfilePhoneSection,
+ UserProfileSecurityPanel,
+ UserProfilePasswordSection,
+ UserProfileMfaSection,
+ UserProfileDeleteSection,
+} from '@clerk/ui/experimental';
+
+export default function Page() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}`;
+
+const provider = () => `'use client'
+import { ClerkProvider } from "@clerk/nextjs";
+
+export function Provider({ children }: { children: any }) {
+ return (
+
+ {children}
+
+ )
+}`;
+
+const layout = () => `import './globals.css';
+import { Inter } from 'next/font/google';
+import { Provider } from './provider';
+
+const inter = Inter({ subsets: ['latin'] });
+
+export const metadata = {
+ title: 'Create Next App',
+ description: 'Generated by create next app',
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}`;
+
+test.describe('composed UserProfile exports @generic', () => {
+ test.describe.configure({ mode: 'serial' });
+ let app: Application;
+ let fakeUser: FakeUser;
+
+ test.beforeAll(async () => {
+ app = await appConfigs.next.appRouter
+ .clone()
+ // The composed exports are bundled into the app (they run in the host React tree and read the
+ // loaded clerk instance via useClerk()), so the app depends on @clerk/ui directly.
+ .addDependency('@clerk/ui', PKGLAB)
+ .addFile('src/app/provider.tsx', provider)
+ .addFile('src/app/layout.tsx', layout)
+ .addFile('src/app/composed/user/page.tsx', composedUserProfilePage)
+ .commit();
+ await app.setup();
+ await app.withEnv(appConfigs.envs.withEmailCodes);
+ await app.dev();
+
+ const m = createTestUtils({ app });
+ fakeUser = m.services.users.createFakeUser({
+ withUsername: true,
+ fictionalEmail: true,
+ withPhoneNumber: true,
+ });
+ await m.services.users.createBapiUser({
+ ...fakeUser,
+ username: undefined,
+ phoneNumber: undefined,
+ });
+ });
+
+ test.afterAll(async () => {
+ await fakeUser.deleteIfExists();
+ await app.teardown();
+ });
+
+ // Sign in as `user` and open the composed profile page. The composed provider renders null until
+ // the user + environment are loaded and emits no `.cl-userProfile-root`, so we wait on a rendered
+ // affordance (the profile section's "Update profile" action) rather than the standard root.
+ const signInAndVisitComposedUserProfile = async (page: Page, context: BrowserContext, user: FakeUser) => {
+ const u = createTestUtils({ app, page, context });
+ await u.po.signIn.goTo();
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
+ await u.po.expect.toBeSignedIn();
+ await u.page.goToRelative('/composed/user');
+ await u.page.getByText(/update profile/i).waitFor({ state: 'visible' });
+ return u;
+ };
+
+ // The flows below reuse the shared `u.po.userProfile` step helpers and mirror the assertions in
+ // `user-profile.test.ts` exactly; only navigation is composed-specific.
+
+ test('can update the username', async ({ page, context }) => {
+ const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
+
+ await u.po.userProfile.clickSetUsername();
+ await u.po.userProfile.waitForSectionCardOpened('username');
+ await u.po.userProfile.typeUsername(fakeUser.username);
+ await u.page.getByText(/Save/i).click();
+ await u.po.userProfile.waitForSectionCardClosed('username');
+
+ const username = await u.page.locator('.cl-profileSectionItem__username').innerText();
+ expect(username).toContain(fakeUser.username);
+ });
+
+ test('can update first and last name', async ({ page, context }) => {
+ const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
+
+ await u.po.userProfile.clickToUpdateProfile();
+ await u.po.userProfile.waitForSectionCardOpened('profile');
+ await u.po.userProfile.typeFirstName('John');
+ await u.po.userProfile.typeLastName('Doe');
+ await u.page.getByText(/Save/i).click();
+ await u.po.userProfile.waitForSectionCardClosed('profile');
+
+ const fullName = await u.page.locator('.cl-profileSectionItem__profile').innerText();
+ expect(fullName).toContain('John Doe');
+ });
+
+ test('can add a new email address', async ({ page, context }) => {
+ const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
+
+ await u.po.userProfile.clickAddEmailAddress();
+ await u.po.userProfile.waitForSectionCardOpened('emailAddresses');
+ const newFakeEmail = `new-${fakeUser.email}`;
+ await u.po.userProfile.typeEmailAddress(newFakeEmail);
+ await u.page.getByRole('button', { name: /^add$/i }).click();
+
+ await u.po.userProfile.enterTestOtpCode();
+
+ await expect(
+ u.page.locator('.cl-profileSectionItem__emailAddresses').filter({ hasText: newFakeEmail }),
+ ).toContainText(newFakeEmail);
+ });
+
+ test('can add a new phone number', async ({ page, context }) => {
+ const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
+
+ await u.po.userProfile.clickAddPhoneNumber();
+ await u.po.userProfile.waitForSectionCardOpened('phoneNumbers');
+ await u.po.userProfile.typePhoneNumber(fakeUser.phoneNumber);
+ await u.page.getByRole('button', { name: /^add$/i }).click();
+
+ await u.po.userProfile.enterTestOtpCode();
+
+ const formattedPhoneNumber = stringPhoneNumber(fakeUser.phoneNumber);
+ await expect(u.page.locator('.cl-profileSectionItem__phoneNumbers')).toContainText(formattedPhoneNumber);
+ });
+
+ test('can add mfa authentication with a phone number', async ({ page, context }) => {
+ const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
+
+ // No Security tab to switch to — the composed security panel is already rendered on the page.
+ await u.page.getByText(/add two-step verification/i).click();
+ await u.page.getByText(/sms code/i).click();
+
+ const formattedPhoneNumber = stringPhoneNumber(fakeUser.phoneNumber);
+ await u.page.getByRole('button', { name: formattedPhoneNumber }).click();
+
+ await u.page.getByText(/sms code verification enabled/i).waitFor({ state: 'visible' });
+ });
+
+ test('can delete the account', async ({ page, context }) => {
+ const m = createTestUtils({ app });
+ const delFakeUser = m.services.users.createFakeUser({
+ withUsername: true,
+ fictionalEmail: true,
+ withPhoneNumber: true,
+ });
+ await m.services.users.createBapiUser({
+ ...delFakeUser,
+ username: undefined,
+ phoneNumber: undefined,
+ });
+
+ const u = await signInAndVisitComposedUserProfile(page, context, delFakeUser);
+
+ // The delete section is rendered directly in the composed security panel (no tab to switch).
+ await u.page.getByRole('button', { name: /delete account/i }).click();
+ await u.page.locator('input[name=deleteConfirmation]').fill('Delete account');
+ await u.page.getByRole('button', { name: /delete account/i }).click();
+
+ await u.po.expect.toBeSignedOut();
+
+ const sessionCookieList = (await u.page.context().cookies()).filter(cookie => cookie.name.startsWith('__session'));
+ expect(sessionCookieList).toHaveLength(0);
+ });
+});
+
+// The composed `OrganizationProfileProvider` renders null until there is an active organization
+// (`useOrganization()`). Each test signs in a user with exactly one membership, and the page
+// activates it via `setActive` before rendering the composed general sections. The section wrappers
+// (`OrganizationProfileProfileSection` / `...LeaveSection` / `...DeleteSection`) render the same
+// components as the standard `` general page.
+const composedOrganizationProfilePage = () => `'use client';
+import { useEffect } from 'react';
+import { useClerk, useOrganization, useOrganizationList } from '@clerk/nextjs';
+import {
+ OrganizationProfileProvider,
+ OrganizationProfileGeneralPanel,
+ OrganizationProfileProfileSection,
+ OrganizationProfileLeaveSection,
+ OrganizationProfileDeleteSection,
+} from '@clerk/ui/experimental';
+
+function EnsureActiveOrganization() {
+ const { setActive } = useClerk();
+ const { organization } = useOrganization();
+ const { isLoaded, userMemberships } = useOrganizationList({ userMemberships: true });
+
+ useEffect(() => {
+ if (!isLoaded || organization) return;
+ const first = userMemberships?.data?.[0]?.organization;
+ if (first && setActive) {
+ void setActive({ organization: first.id });
+ }
+ }, [isLoaded, organization, userMemberships, setActive]);
+
+ return null;
+}
+
+export default function Page() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+}`;
+
+// The composed `OrganizationProfileSecurityPanel` renders the whole standard security page
+// (`OrganizationSecurityPage`) — the SSO overview plus its configuration wizard. Unlike the general
+// tab it has no composable sub-sections, so the panel takes no children. It is the composed
+// counterpart to the security route the standard `` renders.
+const composedOrganizationSecurityPage = () => `'use client';
+import { useEffect } from 'react';
+import { useClerk, useOrganization, useOrganizationList } from '@clerk/nextjs';
+import {
+ OrganizationProfileProvider,
+ OrganizationProfileSecurityPanel,
+} from '@clerk/ui/experimental';
+
+function EnsureActiveOrganization() {
+ const { setActive } = useClerk();
+ const { organization } = useOrganization();
+ const { isLoaded, userMemberships } = useOrganizationList({ userMemberships: true });
+
+ useEffect(() => {
+ if (!isLoaded || organization) return;
+ const first = userMemberships?.data?.[0]?.organization;
+ if (first && setActive) {
+ void setActive({ organization: first.id });
+ }
+ }, [isLoaded, organization, userMemberships, setActive]);
+
+ return null;
+}
+
+export default function Page() {
+ return (
+ <>
+
+
+
+
+ >
+ );
+}`;
+
+test.describe('composed OrganizationProfile exports @generic', () => {
+ test.describe.configure({ mode: 'serial' });
+ let app: Application;
+ let fakeUser: FakeUser;
+ let fakeOrganization: FakeOrganization;
+
+ test.beforeAll(async () => {
+ app = await appConfigs.next.appRouter
+ .clone()
+ .addDependency('@clerk/ui', PKGLAB)
+ .addFile('src/app/provider.tsx', provider)
+ .addFile('src/app/layout.tsx', layout)
+ .addFile('src/app/composed/organization/page.tsx', composedOrganizationProfilePage)
+ .addFile('src/app/composed/organization-security/page.tsx', composedOrganizationSecurityPage)
+ .commit();
+ await app.setup();
+ await app.withEnv(appConfigs.envs.withEmailCodes);
+ await app.dev();
+
+ const m = createTestUtils({ app });
+ fakeUser = m.services.users.createFakeUser({ fictionalEmail: true });
+ const user = await m.services.users.createBapiUser(fakeUser);
+ fakeOrganization = await m.services.users.createFakeOrganization(user.id);
+ });
+
+ test.afterAll(async () => {
+ // The delete test removes its own organization; ignore if this one is already gone.
+ await fakeOrganization.delete().catch(() => {});
+ await fakeUser.deleteIfExists();
+ await app.teardown();
+ });
+
+ // Sign in as `user` and open the composed organization profile page, waiting for the active org's
+ // "Update profile" affordance (the composed provider renders null until an org is active).
+ const signInAndVisitComposedOrganizationProfile = async (page: Page, context: BrowserContext, user: FakeUser) => {
+ const u = createTestUtils({ app, page, context });
+ await u.po.signIn.goTo();
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
+ await u.po.expect.toBeSignedIn();
+ await u.page.goToRelative('/composed/organization');
+ await u.page.getByText(/update profile/i).waitFor({ state: 'visible' });
+ return u;
+ };
+
+ test('renders the composed organization general sections', async ({ page, context }) => {
+ const u = await signInAndVisitComposedOrganizationProfile(page, context, fakeUser);
+
+ // The active organization's name is surfaced in the profile section, and the danger section
+ // (delete/leave) renders for the admin.
+ await expect(u.page.locator('.cl-profileSectionItem__organizationProfile')).toContainText(fakeOrganization.name);
+ await expect(u.page.getByRole('button', { name: /delete organization/i })).toBeVisible();
+ });
+
+ test('can rename the organization', async ({ page, context }) => {
+ const u = await signInAndVisitComposedOrganizationProfile(page, context, fakeUser);
+
+ const newName = `${fakeOrganization.name}-renamed`;
+ await u.page.getByText(/update profile/i).click();
+ const nameInput = u.page.getByLabel('Name', { exact: true });
+ await nameInput.fill(newName);
+ await u.page.getByText(/Save/i).click();
+
+ await expect(u.page.locator('.cl-profileSectionItem__organizationProfile')).toContainText(newName);
+
+ // Assert the mutation actually reached the backend, not just the DOM.
+ const updated = await u.services.clerk.organizations.getOrganization({
+ organizationId: fakeOrganization.organization.id,
+ });
+ expect(updated.name).toBe(newName);
+ });
+
+ test('can delete the organization', async ({ page, context }) => {
+ const m = createTestUtils({ app });
+ const delFakeUser = m.services.users.createFakeUser({ fictionalEmail: true });
+ const delUser = await m.services.users.createBapiUser(delFakeUser);
+ const delOrg = await m.services.users.createFakeOrganization(delUser.id);
+
+ try {
+ const u = await signInAndVisitComposedOrganizationProfile(page, context, delFakeUser);
+
+ await u.page.getByRole('button', { name: /delete organization/i }).click();
+
+ // The confirmation card requires typing the organization name (its placeholder).
+ await expect(u.page.getByText(/are you sure you want to delete this organization/i)).toBeVisible();
+ await u.page.getByPlaceholder(delOrg.name).fill(delOrg.name);
+ await u.page.getByRole('button', { name: /delete organization/i }).click();
+
+ // Unlike the modal component, the composed provider renders null the moment the org is gone
+ // (useOrganization() -> null), so there is no lingering success screen to assert on. The
+ // observable parity outcome is that the deletion reached the backend.
+ await expect
+ .poll(
+ async () => {
+ try {
+ await u.services.clerk.organizations.getOrganization({ organizationId: delOrg.organization.id });
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ { timeout: 15_000 },
+ )
+ .toBe(false);
+ } finally {
+ await delFakeUser.deleteIfExists();
+ }
+ });
+
+ test('renders the composed organization security page', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.po.signIn.goTo();
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+ await u.page.goToRelative('/composed/organization-security');
+
+ // The composed security panel renders the same OrganizationSecurityPage the standard component
+ // shows on its security route: the "Security" page header plus the SSO overview section. This is
+ // the composed counterpart to the security tab (there are no composable sub-sections here).
+ await expect(u.page.getByRole('heading', { name: /^security$/i })).toBeVisible();
+ await expect(u.page.getByText(/^SSO$/)).toBeVisible();
+ await expect(u.page.getByRole('button', { name: /start configuration/i })).toBeVisible();
+ });
+});
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index 81714df9bc6..7498dd70973 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -274,6 +274,20 @@ export class Clerk implements ClerkInterface {
#pageLifecycle: ReturnType | null = null;
#touchThrottledUntil = 0;
#publicEventBus = createClerkEventBus();
+ #moduleManager = new ModuleManager();
+
+ /**
+ * Cross-bundle handle to the ModuleManager. clerk-js is loaded standalone
+ * from the CDN with its own inlined @clerk/shared, so plain property access
+ * is the only channel that reliably crosses that boundary. This getter is
+ * how IsomorphicClerk forwards the manager to consumers that import
+ * @clerk/shared from node_modules (e.g. @clerk/react, @clerk/ui).
+ *
+ * @internal
+ */
+ get __internal_moduleManager(): ModuleManager {
+ return this.#moduleManager;
+ }
get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined {
if (!this.#queryClient) {
@@ -557,7 +571,7 @@ export class Clerk implements ClerkInterface {
() => this,
() => this.environment,
this.#options,
- new ModuleManager(),
+ this.#moduleManager,
),
);
}
diff --git a/packages/react/src/__tests__/isomorphicClerk.test.ts b/packages/react/src/__tests__/isomorphicClerk.test.ts
index df2c5920dc0..b2081acd6a6 100644
--- a/packages/react/src/__tests__/isomorphicClerk.test.ts
+++ b/packages/react/src/__tests__/isomorphicClerk.test.ts
@@ -48,6 +48,31 @@ describe('isomorphicClerk', () => {
}).not.toThrow();
});
+ // Regression: composed/subcomponent UserProfile reads moduleManager via
+ // `useClerk().__internal_moduleManager`. `useClerk()` returns the
+ // IsomorphicClerk wrapper, so its getter must chain through to the loaded
+ // clerk-js's own `__internal_moduleManager`. This plain property access is
+ // the cross-bundle channel: clerk-js ships standalone from the CDN with its
+ // own inlined @clerk/shared, so module-scoped state cannot bridge the two.
+ //
+ // Without this chain, every dynamic-imported feature (Coinbase Wallet, Base,
+ // Stripe, zxcvbn) falls back to a rejecting manager.
+ it('exposes the inner clerk-js moduleManager through the __internal_moduleManager getter', () => {
+ const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
+ const mm = { import: vi.fn(() => Promise.resolve(undefined)) };
+
+ // Before clerk-js loads, the getter is undefined so readers fall back.
+ expect(isomorphicClerk.__internal_moduleManager).toBeUndefined();
+
+ const innerClerk: any = {
+ addListener: vi.fn(),
+ __internal_moduleManager: mm,
+ };
+ (isomorphicClerk as any).replayInterceptedInvocations(innerClerk);
+
+ expect(isomorphicClerk.__internal_moduleManager).toBe(mm);
+ });
+
it('updates props asynchronously after clerkjs has loaded', async () => {
const propsHistory: any[] = [];
const dummyClerkJS = {
diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts
index 15f805e2f9b..e017ab2ddcd 100644
--- a/packages/react/src/isomorphicClerk.ts
+++ b/packages/react/src/isomorphicClerk.ts
@@ -2,6 +2,7 @@ import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
+import type { ModuleManager } from '@clerk/shared/moduleManager';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
__internal_AttemptToEnableEnvironmentSettingResult,
@@ -285,6 +286,18 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
windowNavigate(to, { allowedProtocols });
};
+ /**
+ * Proxies to the inner Clerk instance's ModuleManager. Returns `undefined`
+ * before clerk-js has loaded; composed UI components read this getter
+ * (via `useClerk()`) to resolve dynamic-imported modules and fall back to a
+ * rejecting manager while it is `undefined`.
+ *
+ * @internal
+ */
+ public get __internal_moduleManager(): ModuleManager | undefined {
+ return this.clerkjs?.__internal_moduleManager;
+ }
+
constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts
index 31fe0610746..6803dac6015 100644
--- a/packages/shared/src/types/clerk.ts
+++ b/packages/shared/src/types/clerk.ts
@@ -1,4 +1,6 @@
-import type { ClerkGlobalHookError } from '../errors/globalHookError';
+import type { ClerkGlobalHookError } from '@/errors/globalHookError';
+
+import type { ModuleManager } from '../moduleManager';
import type { ClerkUIConstructor } from '../ui/types';
import type { APIKeysNamespace } from './apiKeys';
import type {
@@ -139,11 +141,13 @@ export type SDKMetadata = {
/**
* A callback function that is called when Clerk resources change.
+ *
* @inline
*/
export type ListenerCallback = (emission: Resources) => void;
/**
* Optional configuration for the `addListener()` method.
+ *
* @param skipInitialEmit - If `true`, the callback will not be called immediately after registration. Defaults to `false`.
* @inline
*/
@@ -188,7 +192,8 @@ export type SetActiveNavigate = (params: {
/**
* A callback that runs after sign out completes.
- * @inline */
+ *
+ @inline */
export type SignOutCallback = () => void | Promise;
/**
@@ -298,6 +303,20 @@ export interface Clerk {
*/
__internal_windowNavigate: (to: URL | string, opts?: { useStaticAllowlistOnly?: boolean }) => void;
+ /**
+ * Internal handle to the bundled ModuleManager. Exposed so framework SDK
+ * wrappers (e.g. IsomorphicClerk) can forward it to composed UI components
+ * that need dynamic-imported modules (Coinbase Wallet, Base, Stripe, zxcvbn).
+ * Plain property access crosses the bundle boundary that other channels
+ * cannot — clerk-js inlines its own @clerk/shared, so module-scoped state is
+ * invisible to consumers loading @clerk/shared from node_modules. It is
+ * `undefined` on a wrapper whose inner clerk-js has not loaded yet, so
+ * readers must handle the absent case.
+ *
+ * @internal
+ */
+ __internal_moduleManager: ModuleManager | undefined;
+
frontendApi: string;
/** Your Clerk [Publishable Key](!publishable-key). */
@@ -317,6 +336,7 @@ export interface Clerk {
/**
* Indicates whether the instance is being loaded in a standard browser environment. Set to `false` on native platforms where cookies cannot be set. When `undefined`, Clerk assumes a standard browser.
+ *
* @inline
*/
isStandardBrowser: boolean | undefined;
@@ -350,6 +370,7 @@ export interface Clerk {
* `effect()` that can be used to subscribe to changes from Signals.
*
* @hidden
+ *
* @experimental This experimental API is subject to change.
*/
__internal_state: State;
@@ -398,6 +419,7 @@ export interface Clerk {
/**
* Closes the Clerk Checkout drawer.
+ *
* @hidden
*/
__internal_closeCheckout: () => void;
@@ -412,6 +434,7 @@ export interface Clerk {
/**
* Closes the Clerk PlanDetails drawer.
+ *
* @hidden
*/
__internal_closePlanDetails: () => void;
@@ -426,6 +449,7 @@ export interface Clerk {
/**
* Closes the Clerk SubscriptionDetails drawer.
+ *
* @hidden
*/
__internal_closeSubscriptionDetails: () => void;
@@ -440,12 +464,14 @@ export interface Clerk {
/**
* Closes the Clerk user verification modal.
+ *
* @hidden
*/
__internal_closeReverification: () => void;
/**
* Attempts to enable a environment setting from a development instance, prompting if disabled.
+ *
* @hidden
*/
__internal_attemptToEnableEnvironmentSetting: (
@@ -454,12 +480,14 @@ export interface Clerk {
/**
* Opens the Clerk Enable Organizations prompt for development instance
+ *
* @hidden
*/
__internal_openEnableOrganizationsPrompt: (props: __internal_EnableOrganizationsPromptProps) => void;
/**
* Closes the Clerk Enable Organizations modal.
+ *
* @hidden
*/
__internal_closeEnableOrganizationsPrompt: () => void;
@@ -939,12 +967,14 @@ export interface Clerk {
/**
* Returns the configured `afterSignInUrl` of the instance.
+ *
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignInUrl({ params }?: { params?: URLSearchParams }): string;
/**
* Returns the configured `afterSignUpUrl` of the instance.
+ *
* @param params - Optional query parameters to append to the URL.
*/
buildAfterSignUpUrl({ params }?: { params?: URLSearchParams }): string;
@@ -1100,6 +1130,7 @@ export interface Clerk {
/**
* Completes an email link verification flow started by `Clerk.client.signIn.createEmailLinkFlow` or `Clerk.client.signUp.createEmailLinkFlow`, by processing the verification results from the redirect URL query parameters. This method should be called after the user is redirected back from visiting the verification link in their email.
+ *
* @param params - Allows you to define the URLs where the user should be redirected to on successful verification or pending/completed sign-up or sign-in attempts. If the email link is successfully verified on another device, there's a callback function parameter that allows custom code execution.
* @param customNavigate - A function that overrides Clerk's default navigation behavior, allowing custom handling of navigation during sign-up and sign-in flows.
*/
@@ -1385,6 +1416,7 @@ export type ClerkOptions = ClerkOptionsNavigation &
localization?: LocalizationResource;
/**
* Indicates whether Clerk should poll against Clerk's backend every 5 minutes.
+ *
* @default true
*/
polling?: boolean;
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 1a3a78b3f30..ed5467208c3 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -50,6 +50,11 @@
"import": "./dist/themes/experimental.js",
"default": "./dist/themes/experimental.js"
},
+ "./experimental": {
+ "types": "./dist/experimental/index.d.ts",
+ "import": "./dist/experimental/index.js",
+ "default": "./dist/experimental/index.js"
+ },
"./themes/shadcn.css": "./dist/themes/shadcn.css",
"./register": {
"import": {
diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
index 5b9aa8764a0..f247f215933 100644
--- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
+++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
@@ -43,7 +43,7 @@ const AuthenticatedContent = withCoreUserGuard(() => {
);
});
-const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject }) => {
+export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject }) => {
const {
isLoading,
enterpriseConnection,
diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx
index f47d28b8aad..2470257420d 100644
--- a/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx
+++ b/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx
@@ -29,7 +29,7 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => {
({ gap: t.space.$8, color: t.colors.$colorForeground })}
+ sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })}
>
{
export const OrganizationGeneralPage = () => {
return (
- ({ gap: t.space.$8 })}
+
-
-
- ({ marginBottom: t.space.$4 })}
- textVariant='h2'
- />
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
);
};
-const OrganizationProfileSection = () => {
+/**
+ * Renders the organization profile section (name, logo) with inline edit when the user has
+ * `org:sys_profile:manage`.
+ *
+ * @returns The profile section, or `null` when no organization is active.
+ */
+export const OrganizationProfileSection = (): JSX.Element | null => {
const { organization } = useOrganization();
if (!organization) {
@@ -134,7 +127,13 @@ const OrganizationProfileSection = () => {
);
};
-const OrganizationDomainsSection = () => {
+/**
+ * Renders the verified-domains section.
+ *
+ * @returns The domains section, or `null` when domains are disabled, no organization is active, or
+ * there are no domains and the user cannot add any.
+ */
+export const OrganizationDomainsSection = (): JSX.Element | null => {
const { organizationSettings } = useEnvironment();
const { organization, domains } = useOrganization({ domains: { infinite: true } });
const canManageDomains = useProtect({ permission: 'org:sys_domains:manage' });
@@ -190,7 +189,12 @@ const OrganizationDomainsSection = () => {
);
};
-const OrganizationLeaveSection = () => {
+/**
+ * Renders the "leave organization" action in the danger section.
+ *
+ * @returns The leave-organization section, or `null` when no organization is active.
+ */
+export const OrganizationLeaveSection = (): JSX.Element | null => {
const { organization } = useOrganization();
if (!organization) {
@@ -236,7 +240,13 @@ const OrganizationLeaveSection = () => {
);
};
-const OrganizationDeleteSection = () => {
+/**
+ * Renders the "delete organization" action in the danger section.
+ *
+ * @returns The delete-organization section, or `null` when no organization is active, the user
+ * lacks `org:sys_profile:delete`, or admin delete is disabled.
+ */
+export const OrganizationDeleteSection = (): JSX.Element | null => {
const { organization } = useOrganization();
const canDeleteOrganization = useProtect({ permission: 'org:sys_profile:delete' });
diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx
index 0b103b559e0..9109a5e2c1e 100644
--- a/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx
+++ b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx
@@ -55,6 +55,7 @@ export const OrganizationMembers = withCardStateProvider(() => {
{
{
- const { attributes, social, enterpriseSSO } = useEnvironment().userSettings;
const card = useCardState();
- const { user } = useUser();
- const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext();
-
- const showUsername = isAttributeAvailable(attributes.username);
- const showEmail = isAttributeAvailable(attributes.email_address);
- const showPhone = isAttributeAvailable(attributes.phone_number);
- const showConnectedAccounts = social && Object.values(social).filter(p => p.enabled).length > 0;
- const showEnterpriseAccounts = user && enterpriseSSO.enabled;
- const showWeb3 = attributes.web3_wallet?.enabled;
-
- const isEmailImmutable = immutableAttributes.has('email_address');
- const isPhoneImmutable = immutableAttributes.has('phone_number');
- const isUsernameImmutable = immutableAttributes.has('username');
return (
- ({ gap: t.space.$8, color: t.colors.$colorForeground })}
+ ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })}
>
-
-
- ({ marginBottom: t.space.$4 })}
- textVariant='h2'
- />
-
-
- {card.error}
-
-
- {showUsername && }
- {showEmail && (
-
- )}
- {showPhone && (
-
- )}
- {showConnectedAccounts && (
-
- )}
-
- {/*TODO-STEP-UP: Verify that these work as expected*/}
- {showEnterpriseAccounts && }
- {showWeb3 && }
-
-
+
+
+
+
+
+
+
+
);
});
diff --git a/packages/ui/src/components/UserProfile/AccountSections.tsx b/packages/ui/src/components/UserProfile/AccountSections.tsx
new file mode 100644
index 00000000000..f7ceb3c5742
--- /dev/null
+++ b/packages/ui/src/components/UserProfile/AccountSections.tsx
@@ -0,0 +1,90 @@
+import { useUser } from '@clerk/shared/react';
+import type { ReactNode } from 'react';
+
+import { useEnvironment, useUserProfileContext } from '../../contexts';
+import { ConnectedAccountsSection } from './ConnectedAccountsSection';
+import { EmailsSection } from './EmailsSection';
+import { EnterpriseAccountsSection } from './EnterpriseAccountsSection';
+import { PhoneSection } from './PhoneSection';
+import { UsernameSection } from './UsernameSection';
+import { isAttributeAvailable } from './utils';
+import { Web3Section } from './Web3Section';
+
+export function AccountUsername(): ReactNode {
+ const { attributes } = useEnvironment().userSettings;
+ const { immutableAttributes } = useUserProfileContext();
+
+ if (!isAttributeAvailable(attributes.username)) {
+ return null;
+ }
+
+ const isImmutable = immutableAttributes.has('username');
+ return ;
+}
+
+export function AccountEmails(): ReactNode {
+ const { attributes } = useEnvironment().userSettings;
+ const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext();
+
+ if (!isAttributeAvailable(attributes.email_address)) {
+ return null;
+ }
+
+ const isImmutable = immutableAttributes.has('email_address');
+ return (
+
+ );
+}
+
+export function AccountPhone(): ReactNode {
+ const { attributes } = useEnvironment().userSettings;
+ const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext();
+
+ if (!isAttributeAvailable(attributes.phone_number)) {
+ return null;
+ }
+
+ const isImmutable = immutableAttributes.has('phone_number');
+ return (
+
+ );
+}
+
+export function AccountConnectedAccounts(): ReactNode {
+ const { social } = useEnvironment().userSettings;
+ const { shouldAllowIdentificationCreation } = useUserProfileContext();
+
+ if (!social || Object.values(social).filter(p => p.enabled).length === 0) {
+ return null;
+ }
+
+ return ;
+}
+
+export function AccountEnterpriseAccounts(): ReactNode {
+ const { enterpriseSSO } = useEnvironment().userSettings;
+ const { user } = useUser();
+
+ if (!user || !enterpriseSSO.enabled) {
+ return null;
+ }
+
+ return ;
+}
+
+export function AccountWeb3(): ReactNode {
+ const { attributes } = useEnvironment().userSettings;
+ const { shouldAllowIdentificationCreation } = useUserProfileContext();
+
+ if (!attributes.web3_wallet?.enabled) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/packages/ui/src/components/UserProfile/BillingPage.tsx b/packages/ui/src/components/UserProfile/BillingPage.tsx
index 3d555676142..e48beb9b6c4 100644
--- a/packages/ui/src/components/UserProfile/BillingPage.tsx
+++ b/packages/ui/src/components/UserProfile/BillingPage.tsx
@@ -27,7 +27,7 @@ const BillingPageInternal = withCardStateProvider(() => {
({ gap: t.space.$8, color: t.colors.$colorForeground })}
+ sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })}
>
{
- const { attributes, instanceIsPasswordBased } = useEnvironment().userSettings;
const card = useCardState();
- const { user } = useUser();
- const { shouldAllowIdentificationCreation } = useUserProfileContext();
- const showPassword = instanceIsPasswordBased;
- const showPasskey = attributes.passkey?.enabled && shouldAllowIdentificationCreation;
- const showMfa = getSecondFactors(attributes).length > 0;
- const showDelete = user?.deleteSelfEnabled;
return (
- ({ gap: t.space.$8 })}
+
-
-
- ({ marginBottom: t.space.$4 })}
- textVariant='h2'
- />
-
- {card.error}
- {showPassword && }
- {showPasskey && }
- {showMfa && }
-
- {showDelete && }
-
-
+
+
+
+
+
+
);
});
diff --git a/packages/ui/src/components/UserProfile/SecuritySections.tsx b/packages/ui/src/components/UserProfile/SecuritySections.tsx
new file mode 100644
index 00000000000..a1638f8dd7a
--- /dev/null
+++ b/packages/ui/src/components/UserProfile/SecuritySections.tsx
@@ -0,0 +1,43 @@
+import { useUser } from '@clerk/shared/react';
+import type { ReactNode } from 'react';
+
+import { getSecondFactors } from '@/ui/utils/mfa';
+
+import { useEnvironment, useUserProfileContext } from '../../contexts';
+import { MfaSection } from './MfaSection';
+import { PasskeySection } from './PasskeySection';
+import { PasswordSection } from './PasswordSection';
+import { DeleteSection } from './DeleteSection';
+
+export function SecurityPassword(): ReactNode {
+ const { instanceIsPasswordBased } = useEnvironment().userSettings;
+
+ if (!instanceIsPasswordBased) return null;
+
+ return ;
+}
+
+export function SecurityPasskeys(): ReactNode {
+ const { attributes } = useEnvironment().userSettings;
+ const { shouldAllowIdentificationCreation } = useUserProfileContext();
+
+ if (!attributes.passkey?.enabled || !shouldAllowIdentificationCreation) return null;
+
+ return ;
+}
+
+export function SecurityMfa(): ReactNode {
+ const { attributes } = useEnvironment().userSettings;
+
+ if (getSecondFactors(attributes).length === 0) return null;
+
+ return ;
+}
+
+export function SecurityDelete(): ReactNode {
+ const { user } = useUser();
+
+ if (!user?.deleteSelfEnabled) return null;
+
+ return ;
+}
diff --git a/packages/ui/src/components/UserProfile/Web3Form.tsx b/packages/ui/src/components/UserProfile/Web3Form.tsx
index 19c4b320c7a..39cb0e54f0a 100644
--- a/packages/ui/src/components/UserProfile/Web3Form.tsx
+++ b/packages/ui/src/components/UserProfile/Web3Form.tsx
@@ -1,3 +1,4 @@
+import { ClerkRuntimeError } from '@clerk/shared/error';
import { createWeb3 } from '@clerk/shared/internal/clerk-js/web3';
import { useReverification, useUser } from '@clerk/shared/react';
import type { Web3Provider, Web3Strategy } from '@clerk/shared/types';
@@ -56,6 +57,16 @@ export const AddWeb3WalletActionMenu = () => {
throw new Error('user is not defined');
}
+ // `getWeb3Identifier` returns '' when the wallet provider (or its dynamic
+ // import) is unavailable. Posting an empty string yields an unhelpful 422
+ // from FAPI (`form_param_nil` on `web3_wallet`); surface the missing-
+ // provider state locally instead.
+ if (!identifier) {
+ throw new ClerkRuntimeError('A Web3 Wallet extension cannot be found. Please install one to continue.', {
+ code: 'web3_missing_identifier',
+ });
+ }
+
let web3Wallet = await createWeb3Wallet(identifier);
web3Wallet = await web3Wallet?.prepareVerification({ strategy });
const message = web3Wallet?.verification.message as string;
diff --git a/packages/ui/src/components/UserProfile/__tests__/AccountSections.test.tsx b/packages/ui/src/components/UserProfile/__tests__/AccountSections.test.tsx
new file mode 100644
index 00000000000..8e039000601
--- /dev/null
+++ b/packages/ui/src/components/UserProfile/__tests__/AccountSections.test.tsx
@@ -0,0 +1,209 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render, screen } from '@/test/utils';
+import { CardStateProvider } from '@/ui/elements/contexts';
+
+import { clearFetchCache } from '../../../hooks';
+import {
+ AccountConnectedAccounts,
+ AccountEmails,
+ AccountEnterpriseAccounts,
+ AccountPhone,
+ AccountUsername,
+ AccountWeb3,
+} from '../AccountSections';
+
+const { createFixtures } = bindCreateFixtures('UserProfile');
+
+describe('AccountSections — self-gating visibility', () => {
+ beforeEach(() => {
+ clearFetchCache();
+ });
+
+ describe('AccountUsername', () => {
+ it('renders when username is enabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUsername();
+ f.withUser({ email_addresses: ['test@clerk.com'], username: 'testuser' });
+ });
+
+ render(, { wrapper });
+ screen.getByText('testuser');
+ });
+
+ it('returns null when username is disabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ const { container } = render(, { wrapper });
+ expect(container.innerHTML).toBe('');
+ });
+ });
+
+ describe('AccountEmails', () => {
+ it('renders email list when enabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withUser({ email_addresses: ['a@clerk.com', 'b@clerk.com'] });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+ screen.getByText('a@clerk.com');
+ screen.getByText('b@clerk.com');
+ });
+
+ it('returns null when email is disabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ const { container } = render(, { wrapper });
+ expect(container.innerHTML).toBe('');
+ });
+ });
+
+ describe('AccountPhone', () => {
+ it('renders phone list when enabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withPhoneNumber();
+ f.withUser({ email_addresses: ['test@clerk.com'], phone_numbers: ['+11111111111'] });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+ expect(screen.getAllByText(/phone number/i).length).toBeGreaterThan(0);
+ });
+
+ it('returns null when phone is disabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ const { container } = render(, { wrapper });
+ expect(container.innerHTML).toBe('');
+ });
+ });
+
+ describe('AccountConnectedAccounts', () => {
+ it('renders when social providers are enabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withSocialProvider({ provider: 'google' });
+ f.withUser({
+ email_addresses: ['test@clerk.com'],
+ external_accounts: [{ provider: 'google', email_address: 'test@clerk.com' }],
+ });
+ });
+
+ render(, { wrapper });
+ screen.getByText(/connected accounts/i);
+ });
+
+ it('returns null when no social providers are enabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ const { container } = render(, { wrapper });
+ expect(container.innerHTML).toBe('');
+ });
+ });
+
+ describe('AccountEnterpriseAccounts', () => {
+ it('renders when enterprise SSO is enabled and the user has an active enterprise account', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEnterpriseSso();
+ f.withUser({
+ email_addresses: ['test@clerk.com'],
+ enterprise_accounts: [
+ {
+ object: 'enterprise_account',
+ active: true,
+ first_name: 'Laura',
+ last_name: 'Serafim',
+ protocol: 'saml',
+ provider_user_id: null,
+ public_metadata: {},
+ email_address: 'test@clerk.com',
+ provider: 'saml_okta',
+ enterprise_connection: {
+ object: 'enterprise_connection',
+ provider: 'saml_okta',
+ name: 'Okta Workforce',
+ id: 'ent_123',
+ active: true,
+ allow_idp_initiated: false,
+ allow_subdomains: false,
+ disable_additional_identifications: false,
+ sync_user_attributes: false,
+ domain: 'foocorp.com',
+ created_at: 123,
+ updated_at: 123,
+ logo_public_url: null,
+ protocol: 'saml',
+ },
+ verification: {
+ status: 'verified',
+ strategy: 'enterprise_sso',
+ verified_at_client: 'foo',
+ attempts: 0,
+ error: {
+ code: 'identifier_already_signed_in',
+ long_message: "You're already signed in",
+ message: "You're already signed in",
+ },
+ expire_at: 123,
+ id: 'ver_123',
+ object: 'verification',
+ },
+ id: 'eac_123',
+ },
+ ],
+ });
+ });
+
+ render(, { wrapper });
+ screen.getByText(/enterprise accounts/i);
+ });
+
+ it('returns null when enterprise SSO is disabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ const { container } = render(, { wrapper });
+ expect(container.innerHTML).toBe('');
+ });
+ });
+
+ describe('AccountWeb3', () => {
+ it('renders when web3_wallet is enabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withWeb3Wallet();
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ render(, { wrapper });
+ screen.getByText(/web3 wallets/i);
+ });
+
+ it('returns null when web3_wallet is disabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ const { container } = render(, { wrapper });
+ expect(container.innerHTML).toBe('');
+ });
+ });
+});
diff --git a/packages/ui/src/components/UserProfile/__tests__/SecuritySections.test.tsx b/packages/ui/src/components/UserProfile/__tests__/SecuritySections.test.tsx
new file mode 100644
index 00000000000..707cf50b8f2
--- /dev/null
+++ b/packages/ui/src/components/UserProfile/__tests__/SecuritySections.test.tsx
@@ -0,0 +1,114 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render, screen } from '@/test/utils';
+import { CardStateProvider } from '@/ui/elements/contexts';
+
+import { clearFetchCache } from '../../../hooks';
+import { SecurityPassword, SecurityPasskeys, SecurityMfa, SecurityDelete } from '../SecuritySections';
+
+const { createFixtures } = bindCreateFixtures('UserProfile');
+
+describe('SecuritySections — self-gating visibility', () => {
+ beforeEach(() => {
+ clearFetchCache();
+ });
+
+ describe('SecurityPassword', () => {
+ it('renders when instance is password-based', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withPassword();
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+ screen.getByText(/^password/i);
+ });
+
+ it('returns null when instance is not password-based', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ const { container } = render(, { wrapper });
+ expect(container.innerHTML).toBe('');
+ });
+ });
+
+ describe('SecurityPasskeys', () => {
+ it('renders when passkeys are enabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withPasskey();
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+ screen.getByText(/^passkeys/i);
+ });
+
+ it('returns null when passkeys are disabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ const { container } = render(, { wrapper });
+ expect(container.innerHTML).toBe('');
+ });
+ });
+
+ describe('SecurityMfa', () => {
+ it('renders when second factors are available', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withPhoneNumber({ used_for_second_factor: true, second_factors: ['phone_code'] });
+ f.withUser({ email_addresses: ['test@clerk.com'], phone_numbers: ['+11111111111'] });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+ expect(screen.getAllByText(/two-step verification/i).length).toBeGreaterThan(0);
+ });
+
+ it('returns null when no second factors are available', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ const { container } = render(, { wrapper });
+ expect(container.innerHTML).toBe('');
+ });
+ });
+
+ describe('SecurityDelete', () => {
+ it('renders when delete_self_enabled is true', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: true });
+ });
+
+ render(, { wrapper });
+ expect(screen.getAllByText(/delete account/i).length).toBeGreaterThan(0);
+ });
+
+ it('returns null when delete_self_enabled is false', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: false });
+ });
+
+ const { container } = render(, { wrapper });
+ expect(container.innerHTML).toBe('');
+ });
+ });
+});
diff --git a/packages/ui/src/components/UserProfile/__tests__/Web3Section.test.tsx b/packages/ui/src/components/UserProfile/__tests__/Web3Section.test.tsx
index 322ae3935d3..e972492ea2f 100644
--- a/packages/ui/src/components/UserProfile/__tests__/Web3Section.test.tsx
+++ b/packages/ui/src/components/UserProfile/__tests__/Web3Section.test.tsx
@@ -1,10 +1,25 @@
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import { bindCreateFixtures } from '@/test/create-fixtures';
-import { render } from '@/test/utils';
+import { render, waitFor } from '@/test/utils';
import { Web3Section } from '../Web3Section';
+vi.mock('@clerk/shared/internal/clerk-js/web3', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ createWeb3: vi.fn(() => ({
+ // Mirrors the empty-string fallback the shared helper returns when the
+ // wallet provider isn't resolvable (e.g. dynamic `@coinbase/wallet-sdk`
+ // import returned undefined because no module manager propagated to the
+ // composed/subcomponent UserProfile).
+ getWeb3Identifier: vi.fn(() => Promise.resolve('')),
+ generateWeb3Signature: vi.fn(() => Promise.resolve('')),
+ })),
+ };
+});
+
const { createFixtures } = bindCreateFixtures('UserProfile');
const withMetamaskWallet = createFixtures.config(f => {
@@ -130,4 +145,37 @@ describe('Web3Section', () => {
// Admin wallet with just the address
getByText('0xabcd...abcd');
});
+
+ // Regression: composed/subcomponent UserProfile mounts can fall back to a
+ // no-op ModuleManager when IsomorphicClerk doesn't propagate the wrapper's
+ // moduleManager (see packages/react isomorphicClerk test). When that
+ // happens, `createWeb3(...).getWeb3Identifier` returns '' instead of a
+ // wallet address — and we used to POST `{ web3_wallet: '' }` straight to
+ // FAPI, surfacing as a confusing 422 form_param_nil error. Guard the empty
+ // identifier locally so the failure mode is a clear UI message.
+ describe('AddWeb3WalletActionMenu — empty identifier guard', () => {
+ const withCoinbaseEnabled = createFixtures.config(f => {
+ f.withWeb3Wallet({
+ first_factors: ['web3_coinbase_wallet_signature'],
+ verifications: ['web3_coinbase_wallet_signature'],
+ });
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ it('does not POST /me/web3_wallets when getWeb3Identifier returns ""', async () => {
+ const { wrapper, fixtures } = await createFixtures(withCoinbaseEnabled);
+ const createWeb3Wallet = vi.fn();
+ fixtures.clerk.user!.createWeb3Wallet = createWeb3Wallet as any;
+
+ const { getByRole, userEvent, findByText } = render(, { wrapper });
+
+ await userEvent.click(getByRole('button', { name: /Connect wallet/i }));
+ await userEvent.click(getByRole('menuitem', { name: /Coinbase Wallet/i }));
+
+ await findByText(/Web3 Wallet extension cannot be found/i);
+ await waitFor(() => {
+ expect(createWeb3Wallet).not.toHaveBeenCalled();
+ });
+ });
+ });
});
diff --git a/packages/ui/src/composed/APIKeysSection.tsx b/packages/ui/src/composed/APIKeysSection.tsx
new file mode 100644
index 00000000000..3e1369dcef8
--- /dev/null
+++ b/packages/ui/src/composed/APIKeysSection.tsx
@@ -0,0 +1,15 @@
+'use client';
+
+import { Suspense, type ComponentType, type ReactNode } from 'react';
+
+import { CardStateProvider } from '../elements/contexts';
+
+export function APIKeysSection({ page: Page }: { page: ComponentType }): ReactNode {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/composed/BillingSection.tsx b/packages/ui/src/composed/BillingSection.tsx
new file mode 100644
index 00000000000..f6c30a1139e
--- /dev/null
+++ b/packages/ui/src/composed/BillingSection.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import { Suspense, type ComponentType, type ReactNode } from 'react';
+
+import { RouteContext } from '../router/RouteContext';
+import { useBillingRouter } from './useBillingRouter';
+
+type BillingSectionProps = {
+ billing: ComponentType;
+ plans: ComponentType;
+ statement: ComponentType;
+ paymentAttempt: ComponentType;
+};
+
+export function BillingSection({
+ billing: Billing,
+ plans: Plans,
+ statement: Statement,
+ paymentAttempt: PaymentAttempt,
+}: BillingSectionProps): ReactNode {
+ const { router, route } = useBillingRouter();
+
+ let content: ReactNode;
+ switch (route.page) {
+ case 'plans':
+ content = ;
+ break;
+ case 'statement':
+ content = ;
+ break;
+ case 'payment-attempt':
+ content = ;
+ break;
+ default:
+ content = ;
+ }
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/packages/ui/src/composed/OrganizationProfile/APIKeys.tsx b/packages/ui/src/composed/OrganizationProfile/APIKeys.tsx
new file mode 100644
index 00000000000..2dd72af55cc
--- /dev/null
+++ b/packages/ui/src/composed/OrganizationProfile/APIKeys.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { lazy, type ReactNode } from 'react';
+
+import { APIKeysSection } from '../APIKeysSection';
+
+const OrganizationAPIKeysPage = lazy(() =>
+ import('../../components/OrganizationProfile/OrganizationAPIKeysPage').then(m => ({
+ default: m.OrganizationAPIKeysPage,
+ })),
+);
+
+export const OrganizationProfileAPIKeysPanel = (): ReactNode => ;
diff --git a/packages/ui/src/composed/OrganizationProfile/Billing.tsx b/packages/ui/src/composed/OrganizationProfile/Billing.tsx
new file mode 100644
index 00000000000..aac613207ae
--- /dev/null
+++ b/packages/ui/src/composed/OrganizationProfile/Billing.tsx
@@ -0,0 +1,38 @@
+'use client';
+
+import { lazy, type ReactNode } from 'react';
+
+import { BillingSection } from '../BillingSection';
+
+const OrganizationBillingPage = lazy(() =>
+ import('../../components/OrganizationProfile/OrganizationBillingPage').then(m => ({
+ default: m.OrganizationBillingPage,
+ })),
+);
+
+const OrganizationPlansPage = lazy(() =>
+ import('../../components/OrganizationProfile/OrganizationPlansPage').then(m => ({
+ default: m.OrganizationPlansPage,
+ })),
+);
+
+const OrganizationStatementPage = lazy(() =>
+ import('../../components/OrganizationProfile/OrganizationStatementPage').then(m => ({
+ default: m.OrganizationStatementPage,
+ })),
+);
+
+const OrganizationPaymentAttemptPage = lazy(() =>
+ import('../../components/OrganizationProfile/OrganizationPaymentAttemptPage').then(m => ({
+ default: m.OrganizationPaymentAttemptPage,
+ })),
+);
+
+export const OrganizationProfileBillingPanel = (): ReactNode => (
+
+);
diff --git a/packages/ui/src/composed/OrganizationProfile/General.tsx b/packages/ui/src/composed/OrganizationProfile/General.tsx
new file mode 100644
index 00000000000..fdf8bbeacdf
--- /dev/null
+++ b/packages/ui/src/composed/OrganizationProfile/General.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import type { PropsWithChildren, ReactNode } from 'react';
+
+import { CardStateProvider, useCardState } from '@/ui/elements/contexts';
+import { ProfileCard } from '@/ui/elements/ProfileCard';
+
+import { OrganizationGeneralPage } from '../../components/OrganizationProfile/OrganizationGeneralPage';
+import { localizationKeys } from '../../customizables';
+import { PageContext } from '../PageContext';
+
+function GeneralComposed({ children }: PropsWithChildren): ReactNode {
+ const card = useCardState();
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export function OrganizationProfileGeneralPanel({ children }: PropsWithChildren): ReactNode {
+ if (!children) {
+ return ;
+ }
+
+ // The section confirmation forms (leave/delete) call useCardState(), so children must be wrapped
+ // in a CardStateProvider — mirroring the UserProfile Account/Security composed panels.
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/ui/src/composed/OrganizationProfile/GeneralDeleteOrganization.tsx b/packages/ui/src/composed/OrganizationProfile/GeneralDeleteOrganization.tsx
new file mode 100644
index 00000000000..5372a3efbaf
--- /dev/null
+++ b/packages/ui/src/composed/OrganizationProfile/GeneralDeleteOrganization.tsx
@@ -0,0 +1,9 @@
+'use client';
+
+import { OrganizationDeleteSection } from '../../components/OrganizationProfile/OrganizationGeneralPage';
+import { createSection } from '../createSection';
+
+export const OrganizationProfileDeleteSection = createSection(
+ 'OrganizationProfileDeleteSection',
+ OrganizationDeleteSection,
+);
diff --git a/packages/ui/src/composed/OrganizationProfile/GeneralLeaveOrganization.tsx b/packages/ui/src/composed/OrganizationProfile/GeneralLeaveOrganization.tsx
new file mode 100644
index 00000000000..d48f2053227
--- /dev/null
+++ b/packages/ui/src/composed/OrganizationProfile/GeneralLeaveOrganization.tsx
@@ -0,0 +1,9 @@
+'use client';
+
+import { OrganizationLeaveSection } from '../../components/OrganizationProfile/OrganizationGeneralPage';
+import { createSection } from '../createSection';
+
+export const OrganizationProfileLeaveSection = createSection(
+ 'OrganizationProfileLeaveSection',
+ OrganizationLeaveSection,
+);
diff --git a/packages/ui/src/composed/OrganizationProfile/GeneralOrganizationProfile.tsx b/packages/ui/src/composed/OrganizationProfile/GeneralOrganizationProfile.tsx
new file mode 100644
index 00000000000..b58b137287d
--- /dev/null
+++ b/packages/ui/src/composed/OrganizationProfile/GeneralOrganizationProfile.tsx
@@ -0,0 +1,9 @@
+'use client';
+
+import { OrganizationProfileSection } from '../../components/OrganizationProfile/OrganizationGeneralPage';
+import { createSection } from '../createSection';
+
+export const OrganizationProfileProfileSection = createSection(
+ 'OrganizationProfileProfileSection',
+ OrganizationProfileSection,
+);
diff --git a/packages/ui/src/composed/OrganizationProfile/GeneralVerifiedDomains.tsx b/packages/ui/src/composed/OrganizationProfile/GeneralVerifiedDomains.tsx
new file mode 100644
index 00000000000..06f0984b0c4
--- /dev/null
+++ b/packages/ui/src/composed/OrganizationProfile/GeneralVerifiedDomains.tsx
@@ -0,0 +1,18 @@
+'use client';
+
+import type { ReactNode } from 'react';
+
+import { Protect } from '../../common';
+import { OrganizationDomainsSection } from '../../components/OrganizationProfile/OrganizationGeneralPage';
+import { useRequirePage } from '../useRequirePage';
+
+export function OrganizationProfileDomainsSection(): ReactNode {
+ if (!useRequirePage('OrganizationProfileDomainsSection')) {
+ return null;
+ }
+ return (
+
+
+
+ );
+}
diff --git a/packages/ui/src/composed/OrganizationProfile/Members.tsx b/packages/ui/src/composed/OrganizationProfile/Members.tsx
new file mode 100644
index 00000000000..8fc817c76fd
--- /dev/null
+++ b/packages/ui/src/composed/OrganizationProfile/Members.tsx
@@ -0,0 +1,7 @@
+'use client';
+
+import type { ReactNode } from 'react';
+
+import { OrganizationMembers } from '../../components/OrganizationProfile/OrganizationMembers';
+
+export const OrganizationProfileMembersPanel = (): ReactNode => ;
diff --git a/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx b/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx
new file mode 100644
index 00000000000..1cfdd5d17ff
--- /dev/null
+++ b/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import { useClerk, useOrganization, useUser } from '@clerk/shared/react';
+import type { EnvironmentResource, OrganizationProfileProps } from '@clerk/shared/types';
+import type { PropsWithChildren, ReactNode } from 'react';
+
+import type { Appearance } from '@/ui/internal/appearance';
+
+import { OrganizationProfileContext } from '../../contexts/components/OrganizationProfile';
+import { SubscriberTypeContext } from '../../contexts/components/SubscriberType';
+import { fallbackModuleManager, ProfileProviderShell } from '../ProfileProviderShell';
+
+type OrganizationProfileProviderProps = PropsWithChildren<{
+ appearance?: Appearance;
+ afterLeaveOrganizationUrl?: OrganizationProfileProps['afterLeaveOrganizationUrl'];
+ apiKeysProps?: OrganizationProfileProps['apiKeysProps'];
+}>;
+
+export const OrganizationProfileProvider = (props: OrganizationProfileProviderProps): ReactNode => {
+ const { children, appearance, afterLeaveOrganizationUrl, apiKeysProps } = props;
+ const clerk = useClerk();
+ const { isLoaded, user } = useUser();
+ const { organization } = useOrganization();
+
+ const environment = (clerk as any).__internal_environment as EnvironmentResource | null | undefined;
+ const moduleManager = clerk.__internal_moduleManager ?? fallbackModuleManager;
+
+ if (!isLoaded || !user || !organization || !environment) {
+ return null;
+ }
+
+ const orgProfileCtxValue = {
+ componentName: 'OrganizationProfile' as const,
+ mode: 'mounted' as const,
+ routing: 'hash' as const,
+ path: undefined,
+ afterLeaveOrganizationUrl,
+ apiKeysProps,
+ customPages: [],
+ };
+
+ return (
+
+
+ {children}
+
+
+ );
+};
diff --git a/packages/ui/src/composed/OrganizationProfile/Security.tsx b/packages/ui/src/composed/OrganizationProfile/Security.tsx
new file mode 100644
index 00000000000..76020ef963b
--- /dev/null
+++ b/packages/ui/src/composed/OrganizationProfile/Security.tsx
@@ -0,0 +1,22 @@
+'use client';
+
+import { type ReactNode, useRef } from 'react';
+
+import { CardStateProvider } from '@/ui/elements/contexts';
+
+import { OrganizationSecurityPage } from '../../components/OrganizationProfile/OrganizationSecurityPage';
+
+// The security tab has no composable sub-sections — it is an SSO overview plus a configuration
+// wizard driven by internal view state. So the composed panel renders the whole standard security
+// page (`OrganizationSecurityPage`), mirroring what `` shows on its security
+// route. The confirmation/wizard forms call useCardState(), so children must sit under a
+// CardStateProvider, matching how the standard component wraps the page.
+export const OrganizationProfileSecurityPanel = (): ReactNode => {
+ const contentRef = useRef(null);
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/ui/src/composed/OrganizationProfile/index.tsx b/packages/ui/src/composed/OrganizationProfile/index.tsx
new file mode 100644
index 00000000000..fdbad295a9e
--- /dev/null
+++ b/packages/ui/src/composed/OrganizationProfile/index.tsx
@@ -0,0 +1,10 @@
+export { OrganizationProfileProvider } from './OrganizationProfileProvider';
+export { OrganizationProfileGeneralPanel } from './General';
+export { OrganizationProfileMembersPanel } from './Members';
+export { OrganizationProfileBillingPanel } from './Billing';
+export { OrganizationProfileAPIKeysPanel } from './APIKeys';
+export { OrganizationProfileSecurityPanel } from './Security';
+export { OrganizationProfileProfileSection } from './GeneralOrganizationProfile';
+export { OrganizationProfileDomainsSection } from './GeneralVerifiedDomains';
+export { OrganizationProfileLeaveSection } from './GeneralLeaveOrganization';
+export { OrganizationProfileDeleteSection } from './GeneralDeleteOrganization';
diff --git a/packages/ui/src/composed/PageContext.tsx b/packages/ui/src/composed/PageContext.tsx
new file mode 100644
index 00000000000..b16f319d803
--- /dev/null
+++ b/packages/ui/src/composed/PageContext.tsx
@@ -0,0 +1,5 @@
+import { createContext } from 'react';
+
+type PageId = 'account' | 'security' | 'general';
+
+export const PageContext = createContext(null);
diff --git a/packages/ui/src/composed/ProfileProviderShell.tsx b/packages/ui/src/composed/ProfileProviderShell.tsx
new file mode 100644
index 00000000000..d1abf54b29c
--- /dev/null
+++ b/packages/ui/src/composed/ProfileProviderShell.tsx
@@ -0,0 +1,150 @@
+'use client';
+
+// Composed UserProfile / OrganizationProfile mount outside the clerk-js portal
+// tree, so this shell rebuilds the providers normally split between
+// `LazyProviders` and `LazyComponentRenderer` / `LazyModalRenderer` in
+// `packages/ui/src/lazyModules/providers.tsx`. `ClerkContextProvider` is
+// intentionally omitted — the consumer's `` supplies `clerk` via
+// `useClerk()`. The emotion cache is keyed per clerk instance in
+// `styleCacheStore` so sibling composed roots don't duplicate style insertions.
+
+import { ClerkRuntimeError } from '@clerk/shared/error';
+import type { ModuleManager } from '@clerk/shared/moduleManager';
+import type { EnvironmentResource, LoadedClerk } from '@clerk/shared/types';
+// eslint-disable-next-line no-restricted-imports
+import { CacheProvider } from '@emotion/react';
+import type { PropsWithChildren, ReactNode } from 'react';
+import { useMemo } from 'react';
+
+import { AppearanceProvider } from '@/ui/customizables/AppearanceContext';
+import { FlowMetadataProvider } from '@/ui/elements/contexts';
+import type { Appearance, Elements } from '@/ui/internal/appearance';
+import { getStyleCache, setStyleCache } from '@/ui/internal/styleCacheStore';
+import { RouteContext } from '@/ui/router/RouteContext';
+import { InternalThemeProvider } from '@/ui/styledSystem';
+import { createEmotionCache } from '@/ui/styledSystem/createEmotionCache';
+import { extractCssLayerNameFromAppearance } from '@/ui/utils/extractCssLayerNameFromAppearance';
+
+import { EnvironmentProvider } from '../contexts/EnvironmentContext';
+import { ModuleManagerProvider } from '../contexts/ModuleManagerContext';
+import { OptionsProvider } from '../contexts/OptionsContext';
+import { AppearanceOverrides } from '../elements/AppearanceOverrides';
+import { createComposedRouter } from './stubRouter';
+
+// Used when `clerk.__internal_moduleManager` is `undefined`. In a correctly wired app clerk-js
+// exposes its ModuleManager through that getter, so reaching this means the loaded clerk-js is too
+// old to expose it (an older clerk-js also predates composed profiles entirely). Fail loudly on the
+// first dynamic import (Web3, billing, password strength) instead of silently resolving `undefined`
+// and surfacing later as an opaque access on the missing module.
+export const fallbackModuleManager: ModuleManager = {
+ import: () =>
+ Promise.reject(
+ new ClerkRuntimeError(
+ 'Composed profile components could not resolve a Clerk module manager: this Clerk instance does not expose one. This usually means the loaded @clerk/clerk-js is too old to support composed profiles.',
+ { code: 'composed_module_manager_unavailable' },
+ ),
+ ),
+};
+
+const composedOverrides: Elements = {
+ profilePageContent: { padding: 0 },
+};
+
+type ProfileProviderShellProps = PropsWithChildren<{
+ clerk: LoadedClerk;
+ environment: EnvironmentResource;
+ moduleManager: ModuleManager;
+ appearanceKey: 'userProfile' | 'organizationProfile';
+ flow: 'userProfile' | 'organizationProfile';
+ globalAppearance: Appearance | undefined;
+ appearance?: Appearance;
+}>;
+
+type SharedStyleCacheProviderProps = PropsWithChildren<{
+ clerk: LoadedClerk;
+ nonce?: string;
+ cssLayerName?: string;
+}>;
+
+// One emotion cache per clerk instance, so sibling composed roots share inserts.
+function SharedStyleCacheProvider({ clerk, nonce, cssLayerName, children }: SharedStyleCacheProviderProps): ReactNode {
+ const cache = useMemo(() => {
+ const existing = getStyleCache(clerk);
+ if (existing) {
+ return existing;
+ }
+ const next = createEmotionCache({ nonce, cssLayerName });
+ setStyleCache(clerk, next);
+ return next;
+ }, [clerk, nonce, cssLayerName]);
+
+ return {children};
+}
+
+export function ProfileProviderShell({
+ children,
+ clerk,
+ environment,
+ moduleManager,
+ appearanceKey,
+ flow,
+ globalAppearance,
+ appearance,
+}: ProfileProviderShellProps): ReactNode {
+ // currentPath is left empty: composed has no Clerk-internal navigation. Each
+ // section owns its own CardStateProvider, so errors clear on section unmount
+ // (single-section mounting). Side-by-side sections keep independent error
+ // state — a consumer URL change wouldn't be a meaningful signal to clear
+ // either, so observing it would only cause spurious clears.
+ const router = useMemo(() => createComposedRouter(clerk.navigate), [clerk]);
+ // Match the portal path's normalization (Components.tsx:209) so a cssLayerName
+ // nested inside appearance.theme gets hoisted to top-level for @layer wrapping.
+ const normalizedGlobalAppearance = useMemo(
+ () => extractCssLayerNameFromAppearance(globalAppearance),
+ [globalAppearance],
+ );
+ const options = useMemo(
+ () => ({
+ localization: clerk.__internal_getOption('localization'),
+ supportEmail: clerk.__internal_getOption('supportEmail'),
+ }),
+ [clerk],
+ );
+
+ return (
+
+ {/* parsed appearance for cl-* styled components */}
+
+ {/* flow= for Flow.Root/Part data-clerk-* selectors */}
+
+ {/* Emotion ThemeProvider over parsed theme */}
+
+ {/* dynamic-import bridge (Web3) */}
+
+ {/* threads localization + supportEmail from the consumer's */}
+
+ {/* read by useEnvironment() across MFA/account sections */}
+
+ {/* router stub: navigate→clerk.navigate, matches/refresh no-op */}
+
+ {/* zero out profilePageContent padding when embedded */}
+ {children}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/composed/UserProfile/APIKeys.tsx b/packages/ui/src/composed/UserProfile/APIKeys.tsx
new file mode 100644
index 00000000000..e627775110e
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/APIKeys.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { lazy, type ReactNode } from 'react';
+
+import { APIKeysSection } from '../APIKeysSection';
+
+const APIKeysPage = lazy(() =>
+ import('../../components/UserProfile/APIKeysPage').then(m => ({
+ default: m.APIKeysPage,
+ })),
+);
+
+export const UserProfileAPIKeysPanel = (): ReactNode => ;
diff --git a/packages/ui/src/composed/UserProfile/Account.tsx b/packages/ui/src/composed/UserProfile/Account.tsx
new file mode 100644
index 00000000000..5672ae47c38
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/Account.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import type { PropsWithChildren, ReactNode } from 'react';
+
+import { CardStateProvider, useCardState } from '@/ui/elements/contexts';
+import { ProfileCard } from '@/ui/elements/ProfileCard';
+
+import { AccountPage } from '../../components/UserProfile/AccountPage';
+import { localizationKeys } from '../../customizables';
+import { PageContext } from '../PageContext';
+
+function AccountComposed({ children }: PropsWithChildren): ReactNode {
+ const card = useCardState();
+ return (
+
+ ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })}
+ >
+ {children}
+
+
+ );
+}
+
+export function UserProfileAccountPanel({ children }: PropsWithChildren): ReactNode {
+ if (!children) {
+ return ;
+ }
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/ui/src/composed/UserProfile/AccountConnectedAccounts.tsx b/packages/ui/src/composed/UserProfile/AccountConnectedAccounts.tsx
new file mode 100644
index 00000000000..280352a05ef
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/AccountConnectedAccounts.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { AccountConnectedAccounts as Section } from '../../components/UserProfile/AccountSections';
+import { createSection } from '../createSection';
+
+export const UserProfileConnectedAccountsSection = createSection('UserProfileConnectedAccountsSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/AccountEmails.tsx b/packages/ui/src/composed/UserProfile/AccountEmails.tsx
new file mode 100644
index 00000000000..f4aab0d638b
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/AccountEmails.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { AccountEmails as Section } from '../../components/UserProfile/AccountSections';
+import { createSection } from '../createSection';
+
+export const UserProfileEmailSection = createSection('UserProfileEmailSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/AccountEnterpriseAccounts.tsx b/packages/ui/src/composed/UserProfile/AccountEnterpriseAccounts.tsx
new file mode 100644
index 00000000000..e0f6e527b9b
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/AccountEnterpriseAccounts.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { AccountEnterpriseAccounts as Section } from '../../components/UserProfile/AccountSections';
+import { createSection } from '../createSection';
+
+export const UserProfileEnterpriseAccountsSection = createSection('UserProfileEnterpriseAccountsSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/AccountPhone.tsx b/packages/ui/src/composed/UserProfile/AccountPhone.tsx
new file mode 100644
index 00000000000..b24c5eaa551
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/AccountPhone.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { AccountPhone as Section } from '../../components/UserProfile/AccountSections';
+import { createSection } from '../createSection';
+
+export const UserProfilePhoneSection = createSection('UserProfilePhoneSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/AccountProfile.tsx b/packages/ui/src/composed/UserProfile/AccountProfile.tsx
new file mode 100644
index 00000000000..0e5ee1d5499
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/AccountProfile.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { UserProfileSection } from '../../components/UserProfile/UserProfileSection';
+import { createSection } from '../createSection';
+
+export const UserProfileProfileSection = createSection('UserProfileProfileSection', UserProfileSection);
diff --git a/packages/ui/src/composed/UserProfile/AccountUsername.tsx b/packages/ui/src/composed/UserProfile/AccountUsername.tsx
new file mode 100644
index 00000000000..8976d221197
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/AccountUsername.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { AccountUsername as Section } from '../../components/UserProfile/AccountSections';
+import { createSection } from '../createSection';
+
+export const UserProfileUsernameSection = createSection('UserProfileUsernameSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/AccountWeb3.tsx b/packages/ui/src/composed/UserProfile/AccountWeb3.tsx
new file mode 100644
index 00000000000..44b1df38425
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/AccountWeb3.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { AccountWeb3 as Section } from '../../components/UserProfile/AccountSections';
+import { createSection } from '../createSection';
+
+export const UserProfileWeb3Section = createSection('UserProfileWeb3Section', Section);
diff --git a/packages/ui/src/composed/UserProfile/Billing.tsx b/packages/ui/src/composed/UserProfile/Billing.tsx
new file mode 100644
index 00000000000..a8ad004e6f2
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/Billing.tsx
@@ -0,0 +1,38 @@
+'use client';
+
+import { lazy, type ReactNode } from 'react';
+
+import { BillingSection } from '../BillingSection';
+
+const BillingPage = lazy(() =>
+ import('../../components/UserProfile/BillingPage').then(m => ({
+ default: m.BillingPage,
+ })),
+);
+
+const PlansPage = lazy(() =>
+ import('../../components/UserProfile/PlansPage').then(m => ({
+ default: m.PlansPage,
+ })),
+);
+
+const StatementPage = lazy(() =>
+ import('../../components/Statements').then(m => ({
+ default: m.StatementPage,
+ })),
+);
+
+const PaymentAttemptPage = lazy(() =>
+ import('../../components/PaymentAttempts').then(m => ({
+ default: m.PaymentAttemptPage,
+ })),
+);
+
+export const UserProfileBillingPanel = (): ReactNode => (
+
+);
diff --git a/packages/ui/src/composed/UserProfile/Security.tsx b/packages/ui/src/composed/UserProfile/Security.tsx
new file mode 100644
index 00000000000..451e545467c
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/Security.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import type { PropsWithChildren, ReactNode } from 'react';
+
+import { CardStateProvider, useCardState } from '@/ui/elements/contexts';
+import { ProfileCard } from '@/ui/elements/ProfileCard';
+
+import { SecurityPage } from '../../components/UserProfile/SecurityPage';
+import { localizationKeys } from '../../customizables';
+import { PageContext } from '../PageContext';
+
+function SecurityComposed({ children }: PropsWithChildren): ReactNode {
+ const card = useCardState();
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export function UserProfileSecurityPanel({ children }: PropsWithChildren): ReactNode {
+ if (!children) {
+ return ;
+ }
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/ui/src/composed/UserProfile/SecurityActiveDevices.tsx b/packages/ui/src/composed/UserProfile/SecurityActiveDevices.tsx
new file mode 100644
index 00000000000..0f25cfad6ac
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/SecurityActiveDevices.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { ActiveDevicesSection } from '../../components/UserProfile/ActiveDevicesSection';
+import { createSection } from '../createSection';
+
+export const UserProfileActiveDevicesSection = createSection('UserProfileActiveDevicesSection', ActiveDevicesSection);
diff --git a/packages/ui/src/composed/UserProfile/SecurityDelete.tsx b/packages/ui/src/composed/UserProfile/SecurityDelete.tsx
new file mode 100644
index 00000000000..9e116c958de
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/SecurityDelete.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { SecurityDelete as Section } from '../../components/UserProfile/SecuritySections';
+import { createSection } from '../createSection';
+
+export const UserProfileDeleteSection = createSection('UserProfileDeleteSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/SecurityMfa.tsx b/packages/ui/src/composed/UserProfile/SecurityMfa.tsx
new file mode 100644
index 00000000000..ad5c61564d6
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/SecurityMfa.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { SecurityMfa as Section } from '../../components/UserProfile/SecuritySections';
+import { createSection } from '../createSection';
+
+export const UserProfileMfaSection = createSection('UserProfileMfaSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/SecurityPasskeys.tsx b/packages/ui/src/composed/UserProfile/SecurityPasskeys.tsx
new file mode 100644
index 00000000000..b0cf767a6d1
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/SecurityPasskeys.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { SecurityPasskeys as Section } from '../../components/UserProfile/SecuritySections';
+import { createSection } from '../createSection';
+
+export const UserProfilePasskeysSection = createSection('UserProfilePasskeysSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/SecurityPassword.tsx b/packages/ui/src/composed/UserProfile/SecurityPassword.tsx
new file mode 100644
index 00000000000..3a4ad201999
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/SecurityPassword.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { SecurityPassword as Section } from '../../components/UserProfile/SecuritySections';
+import { createSection } from '../createSection';
+
+export const UserProfilePasswordSection = createSection('UserProfilePasswordSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx
new file mode 100644
index 00000000000..a7b1e6f9311
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import { useClerk, useUser } from '@clerk/shared/react';
+import type { EnvironmentResource, OAuthProvider, OAuthScope, UserProfileProps } from '@clerk/shared/types';
+import type { PropsWithChildren, ReactNode } from 'react';
+
+import type { Appearance } from '@/ui/internal/appearance';
+
+import { UserProfileContext } from '../../contexts/components/UserProfile';
+import { fallbackModuleManager, ProfileProviderShell } from '../ProfileProviderShell';
+
+type UserProfileProviderProps = PropsWithChildren<{
+ appearance?: Appearance;
+ additionalOAuthScopes?: Partial>;
+ apiKeysProps?: UserProfileProps['apiKeysProps'];
+}>;
+
+export const UserProfileProvider = (props: UserProfileProviderProps): ReactNode => {
+ const { children, appearance, additionalOAuthScopes, apiKeysProps } = props;
+ const clerk = useClerk();
+ const { isLoaded, user } = useUser();
+
+ const environment = (clerk as any).__internal_environment as EnvironmentResource | null | undefined;
+ const moduleManager = clerk.__internal_moduleManager ?? fallbackModuleManager;
+
+ if (!isLoaded || !user || !environment) {
+ return null;
+ }
+
+ const userProfileCtxValue = {
+ componentName: 'UserProfile' as const,
+ mode: 'mounted' as const,
+ routing: 'hash' as const,
+ path: undefined,
+ additionalOAuthScopes,
+ apiKeysProps,
+ customPages: [],
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/ui/src/composed/UserProfile/index.tsx b/packages/ui/src/composed/UserProfile/index.tsx
new file mode 100644
index 00000000000..7de69d4d962
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/index.tsx
@@ -0,0 +1,17 @@
+export { UserProfileProvider } from './UserProfileProvider';
+export { UserProfileAccountPanel } from './Account';
+export { UserProfileSecurityPanel } from './Security';
+export { UserProfileBillingPanel } from './Billing';
+export { UserProfileAPIKeysPanel } from './APIKeys';
+export { UserProfileProfileSection } from './AccountProfile';
+export { UserProfileUsernameSection } from './AccountUsername';
+export { UserProfileEmailSection } from './AccountEmails';
+export { UserProfilePhoneSection } from './AccountPhone';
+export { UserProfileConnectedAccountsSection } from './AccountConnectedAccounts';
+export { UserProfileEnterpriseAccountsSection } from './AccountEnterpriseAccounts';
+export { UserProfileWeb3Section } from './AccountWeb3';
+export { UserProfilePasswordSection } from './SecurityPassword';
+export { UserProfilePasskeysSection } from './SecurityPasskeys';
+export { UserProfileMfaSection } from './SecurityMfa';
+export { UserProfileActiveDevicesSection } from './SecurityActiveDevices';
+export { UserProfileDeleteSection } from './SecurityDelete';
diff --git a/packages/ui/src/composed/__tests__/OrganizationProfile.test.tsx b/packages/ui/src/composed/__tests__/OrganizationProfile.test.tsx
new file mode 100644
index 00000000000..38133f9e5a6
--- /dev/null
+++ b/packages/ui/src/composed/__tests__/OrganizationProfile.test.tsx
@@ -0,0 +1,47 @@
+import type { ClerkPaginatedResponse, OrganizationMembershipResource } from '@clerk/shared/types';
+import { describe, expect, it } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render, screen, waitFor } from '@/test/utils';
+
+import { OrganizationGeneralPage } from '../../components/OrganizationProfile/OrganizationGeneralPage';
+
+const { createFixtures } = bindCreateFixtures('OrganizationProfile');
+
+describe('Experimental OrganizationProfile', () => {
+ describe('General page', () => {
+ it('renders the organization general page', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] });
+ });
+
+ fixtures.clerk.organization?.getDomains.mockReturnValue(
+ Promise.resolve({
+ data: [],
+ total_count: 0,
+ }),
+ );
+
+ render(, { wrapper });
+ screen.getByText('General');
+ });
+
+ it('shows organization name', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] });
+ });
+
+ fixtures.clerk.organization?.getDomains.mockReturnValue(
+ Promise.resolve({
+ data: [],
+ total_count: 0,
+ }),
+ );
+
+ render(, { wrapper });
+ screen.getByText('TestOrg');
+ });
+ });
+});
diff --git a/packages/ui/src/composed/__tests__/OrganizationProfileSections.test.tsx b/packages/ui/src/composed/__tests__/OrganizationProfileSections.test.tsx
new file mode 100644
index 00000000000..eedfbdf96ad
--- /dev/null
+++ b/packages/ui/src/composed/__tests__/OrganizationProfileSections.test.tsx
@@ -0,0 +1,150 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render, screen, waitFor } from '@/test/utils';
+
+import { clearFetchCache } from '../../hooks';
+import { OrganizationProfileGeneralPanel } from '../OrganizationProfile/General';
+import { OrganizationProfileDeleteSection } from '../OrganizationProfile/GeneralDeleteOrganization';
+import { OrganizationProfileLeaveSection } from '../OrganizationProfile/GeneralLeaveOrganization';
+import { OrganizationProfileProfileSection } from '../OrganizationProfile/GeneralOrganizationProfile';
+import { OrganizationProfileDomainsSection } from '../OrganizationProfile/GeneralVerifiedDomains';
+
+const { createFixtures } = bindCreateFixtures('OrganizationProfile');
+
+describe('OrganizationProfile composed sections', () => {
+ beforeEach(() => {
+ clearFetchCache();
+ });
+
+ describe('General — section composition mode', () => {
+ it('renders only declared sections', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withOrganizationDomains();
+ f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] });
+ });
+
+ fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 }));
+
+ const { queryByText } = render(
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('TestOrg');
+ expect(queryByText(/verified domains/i)).not.toBeInTheDocument();
+ });
+
+ it('renders header', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] });
+ });
+
+ fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 }));
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('General');
+ });
+
+ it('OrganizationProfileDomainsSection renders when domains enabled and user has permission', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withOrganizationDomains();
+ f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] });
+ });
+
+ fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 }));
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => screen.getByText(/verified domains/i));
+ });
+
+ it('OrganizationProfileDeleteSection renders null when adminDeleteEnabled is false', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] });
+ });
+
+ fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 }));
+
+ const { queryByText } = render(
+
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('TestOrg');
+ expect(queryByText(/delete organization/i)).not.toBeInTheDocument();
+ });
+
+ it('OrganizationProfileLeaveSection renders leave button', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] });
+ });
+
+ fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 }));
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ expect(screen.getAllByText(/leave organization/i).length).toBeGreaterThan(0);
+ });
+
+ it('renders custom content between sections', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] });
+ });
+
+ fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 }));
+
+ render(
+
+
+ Custom org content
+
+ ,
+ { wrapper },
+ );
+
+ expect(screen.getByTestId('custom-banner')).toBeInTheDocument();
+ screen.getByText('Custom org content');
+ });
+ });
+
+ describe('General — section outside page', () => {
+ it('useRequirePage throws when rendered outside a page component', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ expect(() => render(, { wrapper })).toThrow(
+ ' must be rendered inside a page component',
+ );
+ });
+ });
+});
diff --git a/packages/ui/src/composed/__tests__/UserProfile.test.tsx b/packages/ui/src/composed/__tests__/UserProfile.test.tsx
new file mode 100644
index 00000000000..ba9d832903d
--- /dev/null
+++ b/packages/ui/src/composed/__tests__/UserProfile.test.tsx
@@ -0,0 +1,56 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render, screen, waitFor } from '@/test/utils';
+
+import { AccountPage } from '../../components/UserProfile/AccountPage';
+import { SecurityPage } from '../../components/UserProfile/SecurityPage';
+import { clearFetchCache } from '../../hooks';
+
+const { createFixtures } = bindCreateFixtures('UserProfile');
+
+describe('Experimental UserProfile', () => {
+ beforeEach(() => {
+ clearFetchCache();
+ });
+
+ // The page components (AccountPage/SecurityPage) are thin wrappers that render
+ // the composed panels. The section-visibility matrix is asserted once at the
+ // component level in AccountSections/SecuritySections; here we only cover what
+ // the page level adds — the enterprise-SSO additional-identification guard —
+ // plus a single mount smoke per page.
+ describe('Account page', () => {
+ it('hides add buttons when enterprise SSO disables additional identifications', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withUser({
+ email_addresses: ['test@clerk.com'],
+ enterprise_accounts: [
+ {
+ active: true,
+ enterprise_connection: {
+ disable_additional_identifications: true,
+ },
+ } as any,
+ ],
+ });
+ f.withEnterpriseSso();
+ });
+
+ const { queryByRole } = render(, { wrapper });
+ expect(queryByRole('button', { name: /add email address/i })).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Security page', () => {
+ it('renders active devices section', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+ fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([]));
+
+ render(, { wrapper });
+ await waitFor(() => screen.getByText(/active devices/i));
+ });
+ });
+});
diff --git a/packages/ui/src/composed/__tests__/UserProfileSections.test.tsx b/packages/ui/src/composed/__tests__/UserProfileSections.test.tsx
new file mode 100644
index 00000000000..1fcb41b7502
--- /dev/null
+++ b/packages/ui/src/composed/__tests__/UserProfileSections.test.tsx
@@ -0,0 +1,320 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render, screen, waitFor } from '@/test/utils';
+
+import { clearFetchCache } from '../../hooks';
+import { UserProfileAccountPanel } from '../UserProfile/Account';
+import { UserProfileConnectedAccountsSection } from '../UserProfile/AccountConnectedAccounts';
+import { UserProfileEmailSection } from '../UserProfile/AccountEmails';
+import { UserProfileEnterpriseAccountsSection } from '../UserProfile/AccountEnterpriseAccounts';
+import { UserProfilePhoneSection } from '../UserProfile/AccountPhone';
+import { UserProfileProfileSection } from '../UserProfile/AccountProfile';
+import { UserProfileUsernameSection } from '../UserProfile/AccountUsername';
+import { UserProfileWeb3Section } from '../UserProfile/AccountWeb3';
+import { UserProfileSecurityPanel } from '../UserProfile/Security';
+import { UserProfileActiveDevicesSection } from '../UserProfile/SecurityActiveDevices';
+import { UserProfileDeleteSection } from '../UserProfile/SecurityDelete';
+import { UserProfilePasswordSection } from '../UserProfile/SecurityPassword';
+
+const { createFixtures } = bindCreateFixtures('UserProfile');
+
+describe('UserProfile composed sections', () => {
+ beforeEach(() => {
+ clearFetchCache();
+ });
+
+ describe('Account — inline form flow', () => {
+ it('inline form flow: update profile opens form', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withName();
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' });
+ });
+
+ const { getByRole, getByLabelText, userEvent } = render(, { wrapper });
+ await userEvent.click(getByRole('button', { name: /update profile/i }));
+ await waitFor(() => getByLabelText(/first name/i));
+ expect(getByLabelText(/first name/i)).toBeInTheDocument();
+ });
+ });
+
+ describe('Account — section composition mode', () => {
+ it('renders only declared sections', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withPhoneNumber();
+ f.withUser({
+ first_name: 'Test',
+ last_name: 'User',
+ email_addresses: ['test@clerk.com'],
+ phone_numbers: ['+11111111111'],
+ });
+ });
+
+ const { queryByText } = render(
+
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('Test User');
+ expect(screen.getAllByText(/email address/i).length).toBeGreaterThan(0);
+ expect(queryByText(/phone number/i)).not.toBeInTheDocument();
+ });
+
+ it('renders header', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('Profile details');
+ });
+
+ it('renders custom content between sections', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' });
+ });
+
+ render(
+
+
+ Custom content
+
+ ,
+ { wrapper },
+ );
+
+ expect(screen.getByTestId('custom-banner')).toBeInTheDocument();
+ screen.getByText('Custom content');
+ });
+
+ it('environment guard: disabled email renders null', async () => {
+ const { wrapper } = await createFixtures(f => {
+ // Email NOT enabled
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' });
+ });
+
+ const { queryByText } = render(
+
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('Test User');
+ expect(queryByText(/email address/i)).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Account — individual sections', () => {
+ it('UserProfileProfileSection renders user name', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Jane', last_name: 'Doe' });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('Jane Doe');
+ });
+
+ it('UserProfileUsernameSection renders when enabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUsername();
+ f.withUser({ email_addresses: ['test@clerk.com'], username: 'jdoe' });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('jdoe');
+ });
+
+ it('UserProfileUsernameSection renders null when disabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], username: 'jdoe' });
+ });
+
+ const { container } = render(
+
+
+ ,
+ { wrapper },
+ );
+
+ expect(container.querySelector('[class*="profileSection"]')).not.toBeInTheDocument();
+ });
+
+ it('UserProfileEmailSection renders email list', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withUser({ email_addresses: ['primary@clerk.com', 'secondary@clerk.com'] });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('primary@clerk.com');
+ screen.getByText('secondary@clerk.com');
+ });
+
+ it('UserProfilePhoneSection renders phone list when enabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withPhoneNumber();
+ f.withUser({ email_addresses: ['test@clerk.com'], phone_numbers: ['+11111111111'] });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ expect(screen.getAllByText(/phone number/i).length).toBeGreaterThan(0);
+ });
+
+ it('UserProfileConnectedAccountsSection renders when social providers enabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withSocialProvider({ provider: 'google' });
+ f.withUser({
+ email_addresses: ['test@clerk.com'],
+ external_accounts: [{ provider: 'google', email_address: 'test@clerk.com' }],
+ });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText(/connected accounts/i);
+ });
+
+ it('UserProfileEnterpriseAccountsSection renders null when enterprise SSO disabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ const { queryByText } = render(
+
+
+ ,
+ { wrapper },
+ );
+
+ expect(queryByText(/enterprise accounts/i)).not.toBeInTheDocument();
+ });
+
+ it('UserProfileWeb3Section renders when web3_wallet enabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withWeb3Wallet();
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText(/web3 wallets/i);
+ });
+ });
+
+ describe('Account — section outside page', () => {
+ it('useRequirePage throws when rendered outside a page component', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ expect(() => render(, { wrapper })).toThrow(
+ ' must be rendered inside a page component',
+ );
+ });
+ });
+
+ describe('Security — section composition mode', () => {
+ it('renders only declared sections', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withPassword();
+ f.withPasskey();
+ f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: true });
+ });
+ fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([]));
+
+ const { queryByText } = render(
+
+
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => screen.getByText(/^password/i));
+ screen.getByText(/active devices/i);
+ expect(queryByText(/^passkeys/i)).not.toBeInTheDocument();
+ expect(queryByText(/delete account/i)).not.toBeInTheDocument();
+ });
+
+ it('UserProfileActiveDevicesSection renders without guard', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+ fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([]));
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => screen.getByText(/active devices/i));
+ });
+
+ it('UserProfileDeleteSection respects user flag', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: false });
+ });
+ fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([]));
+
+ const { queryByText } = render(
+
+
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => screen.getByText(/active devices/i));
+ expect(queryByText(/delete account/i)).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/ui/src/composed/__tests__/action-animation.test.tsx b/packages/ui/src/composed/__tests__/action-animation.test.tsx
new file mode 100644
index 00000000000..6b46dfe868f
--- /dev/null
+++ b/packages/ui/src/composed/__tests__/action-animation.test.tsx
@@ -0,0 +1,77 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.unmock('@formkit/auto-animate/react');
+vi.unmock('@formkit/auto-animate');
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render, screen, waitFor } from '@/test/utils';
+
+import { clearFetchCache } from '../../hooks';
+import { UserProfileAccountPanel } from '../UserProfile/Account';
+import { UserProfileEmailSection } from '../UserProfile/AccountEmails';
+import { UserProfileProfileSection } from '../UserProfile/AccountProfile';
+
+const { createFixtures } = bindCreateFixtures('UserProfile');
+
+function findAddAnimationCall(calls: any[]) {
+ return calls.find(call => {
+ const keyframes = call[0];
+ if (!Array.isArray(keyframes)) {
+ return false;
+ }
+ return keyframes.some(
+ (kf: any) => kf.opacity === 0 && typeof kf.transform === 'string' && kf.transform.includes('scale'),
+ );
+ });
+}
+
+describe('Action open animation', () => {
+ beforeEach(() => {
+ clearFetchCache();
+ vi.mocked(Element.prototype.animate).mockClear();
+ });
+
+ it('calls el.animate with add keyframes when "Add email" action opens', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withUser({
+ first_name: 'Test',
+ last_name: 'User',
+ email_addresses: ['test@clerk.com'],
+ });
+ });
+
+ const { userEvent } = render(, { wrapper });
+ vi.mocked(Element.prototype.animate).mockClear();
+
+ await userEvent.click(screen.getByRole('button', { name: /add email address/i }));
+ await waitFor(() => expect(screen.getByLabelText(/email address/i)).toBeInTheDocument());
+
+ expect(findAddAnimationCall(vi.mocked(Element.prototype.animate).mock.calls)).toBeDefined();
+ });
+
+ it('composed sections: "Add email" triggers add animation', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withUser({
+ first_name: 'Test',
+ last_name: 'User',
+ email_addresses: ['test@clerk.com'],
+ });
+ });
+
+ const { userEvent } = render(
+
+
+
+ ,
+ { wrapper },
+ );
+
+ vi.mocked(Element.prototype.animate).mockClear();
+ await userEvent.click(screen.getByRole('button', { name: /add email address/i }));
+ await waitFor(() => expect(screen.getByLabelText(/email address/i)).toBeInTheDocument());
+
+ expect(findAddAnimationCall(vi.mocked(Element.prototype.animate).mock.calls)).toBeDefined();
+ });
+});
diff --git a/packages/ui/src/composed/__tests__/auto-animate-strictmode.test.tsx b/packages/ui/src/composed/__tests__/auto-animate-strictmode.test.tsx
new file mode 100644
index 00000000000..b16319f537b
--- /dev/null
+++ b/packages/ui/src/composed/__tests__/auto-animate-strictmode.test.tsx
@@ -0,0 +1,144 @@
+import { StrictMode } from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// The global vitest setup mocks @formkit/auto-animate to a no-op stub. Restore
+// the real module for this file (and for Animated.tsx's transitive import) so
+// the production useSafeAutoAnimate wiring actually runs.
+vi.mock('@formkit/auto-animate', async importOriginal => await importOriginal());
+vi.mock('@formkit/auto-animate/react', async importOriginal => await importOriginal());
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render, screen } from '@/test/utils';
+
+import { Animated } from '../../elements/Animated';
+
+const { createFixtures } = bindCreateFixtures('UserProfile');
+
+/**
+ * Exercises the production wrapper (which uses useSafeAutoAnimate)
+ * under React StrictMode's mount → cleanup → remount cycle. StrictMode
+ * double-invokes effects and re-runs ref callbacks; without the
+ * destroy-previous-controller guard in useSafeAutoAnimate, a second
+ * MutationObserver would linger on the same node and its remain() animation
+ * would cancel the entrance (add) animation. We assert the user-facing outcome
+ * (add fires, no remain) and that exactly one MutationObserver stays active on
+ * the animated element after the cycle.
+ */
+
+function classifyAnimateCalls(calls: any[]) {
+ const adds: any[] = [];
+ const remains: any[] = [];
+ for (const call of calls) {
+ const keyframes = call[0];
+ if (!Array.isArray(keyframes)) {
+ continue;
+ }
+ if (
+ keyframes.some(
+ (kf: any) => kf.opacity === 0 && typeof kf.transform === 'string' && kf.transform.includes('scale'),
+ )
+ ) {
+ adds.push(call);
+ }
+ if (keyframes.some((kf: any) => typeof kf.transform === 'string' && kf.transform.includes('translate'))) {
+ remains.push(call);
+ }
+ }
+ return { adds, remains };
+}
+
+function AnimatedList({ showChild }: { showChild: boolean }) {
+ return (
+
+ always here
+ {showChild ? new child
: null}
+
+ );
+}
+
+const flush = () => new Promise(resolve => setTimeout(resolve, 50));
+
+describe('Animated under React StrictMode', () => {
+ let animateSpy: ReturnType;
+
+ beforeEach(() => {
+ animateSpy = vi
+ .spyOn(Element.prototype, 'animate')
+ .mockImplementation(() => ({ addEventListener: vi.fn(), cancel: vi.fn(), finished: Promise.resolve() }) as any);
+ });
+
+ afterEach(() => {
+ animateSpy.mockRestore();
+ });
+
+ it('fires the add animation (and no remain) when a child is added', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ const { rerender } = render(
+
+
+ ,
+ { wrapper },
+ );
+
+ await flush();
+ animateSpy.mockClear();
+
+ rerender(
+
+
+ ,
+ );
+
+ await flush();
+
+ const { adds, remains } = classifyAnimateCalls(animateSpy.mock.calls);
+ expect(adds.length).toBeGreaterThanOrEqual(1);
+ // remain() would only fire if a lingering second observer from the
+ // StrictMode remount re-processed the mutation — the guard prevents it.
+ expect(remains.length).toBe(0);
+ });
+
+ it('keeps exactly one active MutationObserver on the element after the StrictMode cycle', async () => {
+ const activeChildListObservers = new Set();
+ const targets = new WeakMap();
+ const origObserve = MutationObserver.prototype.observe;
+ const origDisconnect = MutationObserver.prototype.disconnect;
+
+ MutationObserver.prototype.observe = function (target: Node, options?: MutationObserverInit) {
+ if (options?.childList) {
+ activeChildListObservers.add(this);
+ targets.set(this, target);
+ }
+ return origObserve.call(this, target, options);
+ };
+ MutationObserver.prototype.disconnect = function () {
+ activeChildListObservers.delete(this);
+ return origDisconnect.call(this);
+ };
+
+ try {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ await flush();
+
+ const el = screen.getByText('always here').parentElement;
+ const activeOnEl = [...activeChildListObservers].filter(mo => targets.get(mo) === el).length;
+ expect(activeOnEl).toBe(1);
+ } finally {
+ MutationObserver.prototype.observe = origObserve;
+ MutationObserver.prototype.disconnect = origDisconnect;
+ }
+ });
+});
diff --git a/packages/ui/src/composed/__tests__/composed-provider-wiring.test.tsx b/packages/ui/src/composed/__tests__/composed-provider-wiring.test.tsx
new file mode 100644
index 00000000000..5bdb1b27820
--- /dev/null
+++ b/packages/ui/src/composed/__tests__/composed-provider-wiring.test.tsx
@@ -0,0 +1,569 @@
+import { act } from '@testing-library/react';
+import { useContext } from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render, screen, waitFor } from '@/test/utils';
+import { useModuleManager, useOptions } from '@/ui/contexts';
+import { useAppearance } from '@/ui/customizables/AppearanceContext';
+import { useRouter } from '@/ui/router';
+
+import { OrganizationProfileContext } from '../../contexts/components/OrganizationProfile';
+import { UserProfileContext } from '../../contexts/components/UserProfile';
+import { clearFetchCache } from '../../hooks';
+import { OrganizationProfileProvider } from '../OrganizationProfile/OrganizationProfileProvider';
+import { fallbackModuleManager } from '../ProfileProviderShell';
+import { UserProfileSecurityPanel } from '../UserProfile/Security';
+import { UserProfilePasswordSection } from '../UserProfile/SecurityPassword';
+import { UserProfileProvider } from '../UserProfile/UserProfileProvider';
+
+function patchEnvironment(clerk: any, env: any) {
+ Object.defineProperty(clerk, '__internal_environment', { value: env, configurable: true });
+}
+
+function ModuleManagerProbe() {
+ const mm = useModuleManager();
+ return (
+
+ );
+}
+
+function RouterProbe() {
+ const router = useRouter();
+ return (
+
+ );
+}
+
+describe('UserProfileProvider wiring', () => {
+ const { createFixtures } = bindCreateFixtures('UserProfile');
+
+ beforeEach(() => {
+ clearFetchCache();
+ });
+
+ it("provides the clerk instance's moduleManager to children", async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' });
+ });
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ const probe = screen.getByTestId('mm-probe');
+ expect(probe.dataset.hasMm).toBe('true');
+ });
+
+ it('falls back to fallbackModuleManager when the clerk instance exposes no moduleManager', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' });
+ });
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+ // Simulate a clerk-js too old to expose the getter (returns undefined).
+ Object.defineProperty(fixtures.clerk, '__internal_moduleManager', {
+ value: undefined,
+ configurable: true,
+ });
+
+ function FallbackProbe() {
+ const mm = useModuleManager();
+ return (
+
+ );
+ }
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ expect(screen.getByTestId('mm-fallback-probe').dataset.isFallback).toBe('true');
+ });
+
+ it('provides a router that delegates to clerk.navigate', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' });
+ });
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ const probe = screen.getByTestId('router-probe');
+ expect(probe.dataset.hasNavigate).toBe('true');
+ expect(probe.dataset.hasBaseNavigate).toBe('true');
+ });
+
+ // The shell does NOT track the host app URL. Composed has no Clerk-internal
+ // navigation between sections (consumer remounts ), so there's
+ // no meaningful currentPath to snapshot — and observing the consumer's URL
+ // would just trigger spurious navigation-keyed effects.
+ describe('router.currentPath is decoupled from the host URL', () => {
+ let originalPath: string;
+
+ beforeEach(() => {
+ originalPath = window.location.pathname;
+ });
+
+ afterEach(() => {
+ window.history.replaceState(null, '', originalPath);
+ });
+
+ function CurrentPathProbe() {
+ const router = useRouter();
+ return (
+
+ );
+ }
+
+ it('does not snapshot window.location.pathname into router.currentPath', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+
+ window.history.replaceState(null, '', '/page-a');
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ expect(screen.getByTestId('path-probe').dataset.current).toBe('');
+ });
+
+ it('does not update router.currentPath on popstate', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ act(() => {
+ window.history.pushState(null, '', '/page-b');
+ window.dispatchEvent(new PopStateEvent('popstate'));
+ });
+
+ expect(screen.getByTestId('path-probe').dataset.current).toBe('');
+ });
+ });
+
+ // The end-to-end proof that resolution actually feeds a dynamic import: render
+ // the real composed password section, enable zxcvbn strength, type a password,
+ // and assert the resolved manager's `import` fires for the zxcvbn module. This
+ // exercises the whole chain UserProfileProvider -> ProfileProviderShell ->
+ // ModuleManagerProvider -> useModuleManager -> usePassword -> loadZxcvbn.
+ // (This is the deterministic in-process analog of a Playwright flow, which
+ // cannot run this path without a backend instance that has `show_zxcvbn` on.)
+ it('feeds the resolved moduleManager into the composed password strength import', async () => {
+ // Never settles, so the un-caught `.then` in createValidatePassword does not
+ // surface as an unhandled rejection; we only care that `import` was invoked.
+ const importSpy = vi.fn(() => new Promise(() => {}));
+
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ f.withPassword();
+ f.withPasswordComplexity({ show_zxcvbn: true });
+ });
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+ Object.defineProperty(fixtures.clerk, '__internal_moduleManager', {
+ value: { import: importSpy },
+ configurable: true,
+ });
+
+ const { userEvent, getByRole, getByLabelText } = render(
+
+
+
+
+ ,
+ { wrapper },
+ );
+
+ await userEvent.click(getByRole('button', { name: /set password/i }));
+ await userEvent.type(getByLabelText(/new password/i), 'weak');
+
+ await waitFor(() => {
+ expect(importSpy).toHaveBeenCalledWith('@zxcvbn-ts/core');
+ });
+ });
+
+ it('returns null when user is not loaded', async () => {
+ const { wrapper, fixtures } = await createFixtures();
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+
+ const { container } = render(
+
+
+ ,
+ { wrapper },
+ );
+
+ expect(screen.queryByTestId('should-not-render')).not.toBeInTheDocument();
+ });
+
+ it('cascades globalAppearance from ClerkProvider into composed theme', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' });
+ });
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+
+ // Simulate ClerkProvider setting appearance with colorPrimary
+ fixtures.clerk.__internal_getOption = vi.fn((key: string) => {
+ if (key === 'appearance') {
+ return { variables: { colorPrimary: '#ff0000' } };
+ }
+ return undefined;
+ });
+
+ function AppearanceProbe() {
+ const { parsedInternalTheme } = useAppearance();
+ return (
+
+ );
+ }
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ const probe = screen.getByTestId('appearance-probe');
+ // #ff0000 = hsla(0, 100%, 50%, 1) — the global appearance should cascade
+ expect(probe.dataset.colorPrimary).toBe('hsla(0, 100%, 50%, 1)');
+ });
+
+ it('threads localization from ClerkProvider into composed OptionsProvider', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' });
+ });
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+
+ const expectedLocalization = { locale: 'fr-FR', signIn: { start: { title: 'Bienvenue' } } };
+ const expectedSupportEmail = 'help@clerk.dev';
+ fixtures.clerk.__internal_getOption = vi.fn((key: string) => {
+ if (key === 'localization') {
+ return expectedLocalization;
+ }
+ if (key === 'supportEmail') {
+ return expectedSupportEmail;
+ }
+ return undefined;
+ });
+
+ function OptionsProbe() {
+ const options = useOptions();
+ return (
+
+ );
+ }
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ const probe = screen.getByTestId('options-probe');
+ expect(probe.dataset.locale).toBe('fr-FR');
+ expect(probe.dataset.supportEmail).toBe(expectedSupportEmail);
+ });
+
+ it('returns null when environment is missing', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' });
+ });
+ patchEnvironment(fixtures.clerk, undefined);
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ expect(screen.queryByTestId('should-not-render')).not.toBeInTheDocument();
+ });
+
+ it('forwards apiKeysProps into the UserProfile context', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' });
+ });
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+
+ function ApiKeysProbe() {
+ const ctx = useContext(UserProfileContext);
+ return (
+
+ );
+ }
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ expect(JSON.parse(screen.getByTestId('apikeys-probe').dataset.apiKeys || 'null')).toEqual({ perPage: 5 });
+ });
+});
+
+describe('OrganizationProfileProvider wiring', () => {
+ const { createFixtures } = bindCreateFixtures('OrganizationProfile');
+
+ beforeEach(() => {
+ clearFetchCache();
+ });
+
+ it("provides the clerk instance's moduleManager to children", async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({
+ email_addresses: ['test@clerk.com'],
+ first_name: 'Test',
+ last_name: 'User',
+ organization_memberships: [{ name: 'TestOrg' }],
+ });
+ });
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+ fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 }));
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ const probe = screen.getByTestId('mm-probe');
+ expect(probe.dataset.hasMm).toBe('true');
+ });
+
+ it('falls back to fallbackModuleManager when the clerk instance exposes no moduleManager', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({
+ email_addresses: ['test@clerk.com'],
+ first_name: 'Test',
+ last_name: 'User',
+ organization_memberships: [{ name: 'TestOrg' }],
+ });
+ });
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+ fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 }));
+ Object.defineProperty(fixtures.clerk, '__internal_moduleManager', {
+ value: undefined,
+ configurable: true,
+ });
+
+ function FallbackProbe() {
+ const mm = useModuleManager();
+ return (
+
+ );
+ }
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ expect(screen.getByTestId('org-mm-fallback-probe').dataset.isFallback).toBe('true');
+ });
+
+ it('returns null when organization is not loaded', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' });
+ });
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+
+ const { container } = render(
+
+
+ ,
+ { wrapper },
+ );
+
+ expect(screen.queryByTestId('should-not-render')).not.toBeInTheDocument();
+ });
+
+ it('forwards afterLeaveOrganizationUrl and apiKeysProps into the OrganizationProfile context', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({
+ email_addresses: ['test@clerk.com'],
+ first_name: 'Test',
+ last_name: 'User',
+ organization_memberships: [{ name: 'TestOrg' }],
+ });
+ });
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+ fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 }));
+
+ function OrgProbe() {
+ const ctx = useContext(OrganizationProfileContext);
+ return (
+
+ );
+ }
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ const probe = screen.getByTestId('org-probe');
+ expect(probe.dataset.afterLeave).toBe('/bye');
+ expect(JSON.parse(probe.dataset.apiKeys || 'null')).toEqual({ showDescription: true });
+ });
+});
+
+// The fallback exists to fail loudly instead of silently resolving `undefined`.
+// This is the contract the whole getter refactor protects: when a too-old
+// clerk-js exposes no manager, the first dynamic import (Web3, billing,
+// password strength) must reject with a stable, diagnosable code rather than
+// degrade to an opaque access on a missing module.
+describe('fallbackModuleManager', () => {
+ it('rejects dynamic imports with a composed_module_manager_unavailable code', async () => {
+ await expect(fallbackModuleManager.import('@zxcvbn-ts/core')).rejects.toMatchObject({
+ code: 'composed_module_manager_unavailable',
+ });
+ });
+});
+
+// Both providers were changed identically to resolve the manager through the
+// live `clerk.__internal_moduleManager` getter, which crosses bundle boundaries
+// regardless of how many @clerk/shared copies are installed. A registry keyed by
+// module-scoped state would silently return the wrong manager (or undefined) when
+// the read-side @clerk/shared is a different physical copy than the write-side
+// one. Exposing a getter-only manager (distinct from anything the real Clerk
+// instance registered) and surfacing it proves the read channel is the getter,
+// not a shared registry — pinned here for both providers.
+describe('moduleManager getter resolution', () => {
+ beforeEach(() => {
+ clearFetchCache();
+ });
+
+ const cases = [
+ {
+ name: 'UserProfileProvider',
+ component: 'UserProfile' as const,
+ Provider: UserProfileProvider,
+ testId: 'mm-getter-probe',
+ setup: (f: any) => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ },
+ },
+ {
+ name: 'OrganizationProfileProvider',
+ component: 'OrganizationProfile' as const,
+ Provider: OrganizationProfileProvider,
+ testId: 'org-mm-getter-probe',
+ setup: (f: any) => {
+ f.withOrganizations();
+ f.withUser({
+ email_addresses: ['test@clerk.com'],
+ first_name: 'Test',
+ last_name: 'User',
+ organization_memberships: [{ name: 'TestOrg' }],
+ });
+ },
+ },
+ ];
+
+ it.each(cases)(
+ '$name resolves the moduleManager from clerk.__internal_moduleManager (getter), not a shared registry',
+ async ({ component, Provider, testId, setup }) => {
+ const { createFixtures } = bindCreateFixtures(component);
+ const getterImport = vi.fn(() => Promise.resolve(undefined));
+ const getterModuleManager = { import: getterImport };
+
+ const { wrapper, fixtures } = await createFixtures(setup);
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+ fixtures.clerk.organization?.getDomains?.mockReturnValue(Promise.resolve({ data: [], total_count: 0 }));
+ Object.defineProperty(fixtures.clerk, '__internal_moduleManager', {
+ value: getterModuleManager,
+ configurable: true,
+ });
+
+ function ModuleManagerIdentityProbe() {
+ const mm = useModuleManager();
+ return (
+
+ );
+ }
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ expect(screen.getByTestId(testId).dataset.fromGetter).toBe('true');
+ },
+ );
+});
diff --git a/packages/ui/src/composed/__tests__/stub-limitations.test.ts b/packages/ui/src/composed/__tests__/stub-limitations.test.ts
new file mode 100644
index 00000000000..fffd9d61236
--- /dev/null
+++ b/packages/ui/src/composed/__tests__/stub-limitations.test.ts
@@ -0,0 +1,131 @@
+import { renderHook, act } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { createComposedRouter, stubRouter } from '../stubRouter';
+import { useBillingRouter } from '../useBillingRouter';
+
+describe('createComposedRouter', () => {
+ it('navigate delegates to clerkNavigate for same-origin paths', async () => {
+ const clerkNavigate = vi.fn().mockResolvedValue(undefined);
+ const router = createComposedRouter(clerkNavigate);
+
+ await router.navigate('/dashboard');
+
+ expect(clerkNavigate).toHaveBeenCalledWith('/dashboard');
+ });
+
+ it('navigate delegates to clerkNavigate for relative paths', async () => {
+ const clerkNavigate = vi.fn().mockResolvedValue(undefined);
+ const router = createComposedRouter(clerkNavigate);
+
+ await router.navigate('../');
+
+ expect(clerkNavigate).toHaveBeenCalledWith('../');
+ });
+
+ it('navigate delegates to clerkNavigate for external URLs', async () => {
+ const clerkNavigate = vi.fn().mockResolvedValue(undefined);
+ const router = createComposedRouter(clerkNavigate);
+
+ await router.navigate('https://external.example.com/callback');
+
+ expect(clerkNavigate).toHaveBeenCalledWith('https://external.example.com/callback');
+ });
+
+ it('baseNavigate delegates to clerkNavigate with URL href', async () => {
+ const clerkNavigate = vi.fn().mockResolvedValue(undefined);
+ const router = createComposedRouter(clerkNavigate);
+
+ await router.baseNavigate(new URL('https://example.com/path'));
+
+ expect(clerkNavigate).toHaveBeenCalledWith('https://example.com/path');
+ });
+
+ it('resolve produces URLs relative to current location', () => {
+ const router = createComposedRouter(vi.fn());
+
+ const resolved = router.resolve('/some-path');
+ expect(resolved.pathname).toBe('/some-path');
+ });
+});
+
+describe('createComposedRouter — AIO-only APIs throw in dev', () => {
+ it('matches() throws', () => {
+ const router = createComposedRouter(vi.fn());
+ expect(() => router.matches('/foo')).toThrow(/not supported inside composed sections/);
+ });
+
+ it('refresh() throws', () => {
+ const router = createComposedRouter(vi.fn());
+ expect(() => router.refresh()).toThrow(/not supported inside composed sections/);
+ });
+
+ it('getMatchData() throws', () => {
+ const router = createComposedRouter(vi.fn());
+ expect(() => router.getMatchData('/foo')).toThrow(/not supported inside composed sections/);
+ });
+});
+
+describe('stubRouter fallback', () => {
+ it('is created with window.location.assign as navigator', () => {
+ // stubRouter is a pre-built instance that delegates to window.location.assign.
+ // We can't spy on window.location.assign in jsdom, but we verify it's a valid router.
+ expect(stubRouter.navigate).toBeDefined();
+ expect(stubRouter.baseNavigate).toBeDefined();
+ });
+});
+
+describe('useBillingRouter — in-memory by design', () => {
+ // Composed billing routing is purely React state — the consumer owns the
+ // page URL. Trade-off: back/forward, refresh, and deep-links do not preserve
+ // sub-route or tab state. These tests pin that decision.
+
+ let originalHash: string;
+
+ afterEach(() => {
+ window.location.hash = originalHash ?? '';
+ });
+
+ it('navigate() does not touch window.location.hash', async () => {
+ originalHash = window.location.hash;
+ const { result } = renderHook(() => useBillingRouter());
+
+ await act(async () => {
+ await result.current.router.navigate('plans');
+ });
+
+ expect(window.location.hash).toBe(originalHash);
+ expect(result.current.route.page).toBe('plans');
+ });
+
+ it('navigate() does not push a history entry', async () => {
+ const before = window.history.length;
+ const { result } = renderHook(() => useBillingRouter());
+
+ await act(async () => {
+ await result.current.router.navigate('statement/abc');
+ });
+
+ expect(window.history.length).toBe(before);
+ });
+});
+
+describe('createComposedRouter — SSR safety', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('resolve() does not throw when window is undefined', () => {
+ vi.stubGlobal('window', undefined);
+
+ const router = createComposedRouter(vi.fn());
+ expect(() => router.resolve('/some-path')).not.toThrow();
+ expect(router.resolve('/some-path').pathname).toBe('/some-path');
+ });
+
+ it('stubRouter.navigate is a no-op (does not throw) when window is undefined', async () => {
+ vi.stubGlobal('window', undefined);
+
+ await expect(stubRouter.navigate('/foo')).resolves.toBeUndefined();
+ });
+});
diff --git a/packages/ui/src/composed/__tests__/style-cache-sharing.test.tsx b/packages/ui/src/composed/__tests__/style-cache-sharing.test.tsx
new file mode 100644
index 00000000000..e2fd8e396df
--- /dev/null
+++ b/packages/ui/src/composed/__tests__/style-cache-sharing.test.tsx
@@ -0,0 +1,150 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render } from '@/test/utils';
+import { getStyleCache } from '@/ui/internal/styleCacheStore';
+
+import { clearFetchCache } from '../../hooks';
+import { OrganizationProfileProvider } from '../OrganizationProfile/OrganizationProfileProvider';
+import { UserProfileProvider } from '../UserProfile/UserProfileProvider';
+
+function patchEnvironment(clerk: any, env: any) {
+ Object.defineProperty(clerk, '__internal_environment', { value: env, configurable: true });
+}
+
+describe('composed style cache sharing', () => {
+ const { createFixtures } = bindCreateFixtures('UserProfile');
+
+ beforeEach(() => {
+ clearFetchCache();
+ });
+
+ it('sibling UserProfile + OrganizationProfile roots share one emotion cache per clerk instance', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({
+ email_addresses: ['test@clerk.com'],
+ first_name: 'Test',
+ last_name: 'User',
+ organization_memberships: [{ name: 'TestOrg' }],
+ });
+ });
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+ fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 }));
+
+ render(
+ <>
+
+
+
+
+
+
+ >,
+ { wrapper },
+ );
+
+ const cache = getStyleCache(fixtures.clerk);
+ expect(cache).toBeDefined();
+ expect(cache?.key).toBe('cl-internal');
+
+ // Second sibling must reuse the cache the first sibling set on the WeakMap,
+ // rather than creating a new one. The WeakMap is the sharing channel.
+ const cacheAgain = getStyleCache(fixtures.clerk);
+ expect(cacheAgain).toBe(cache);
+ });
+
+ it('distinct clerk instances get distinct caches', async () => {
+ const first = await createFixtures(f => {
+ f.withUser({ email_addresses: ['a@clerk.com'] });
+ });
+ patchEnvironment(first.fixtures.clerk, first.fixtures.environment);
+
+ const second = await createFixtures(f => {
+ f.withUser({ email_addresses: ['b@clerk.com'] });
+ });
+ patchEnvironment(second.fixtures.clerk, second.fixtures.environment);
+
+ render(
+
+
+ ,
+ { wrapper: first.wrapper },
+ );
+
+ render(
+
+
+ ,
+ { wrapper: second.wrapper },
+ );
+
+ const cacheA = getStyleCache(first.fixtures.clerk);
+ const cacheB = getStyleCache(second.fixtures.clerk);
+
+ expect(cacheA).toBeDefined();
+ expect(cacheB).toBeDefined();
+ expect(cacheA).not.toBe(cacheB);
+ });
+
+ it('first mount initializes the cache with the clerk-provided nonce', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+
+ const expectedNonce = 'test-nonce-abc123';
+ const originalGetOption = fixtures.clerk.__internal_getOption.bind(fixtures.clerk);
+ fixtures.clerk.__internal_getOption = (key: string) => {
+ if (key === 'nonce') {
+ return expectedNonce;
+ }
+ return originalGetOption(key);
+ };
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ const cache = getStyleCache(fixtures.clerk);
+ expect(cache?.sheet.nonce).toBe(expectedNonce);
+ });
+
+ it('wraps emitted CSS in @layer when appearance.cssLayerName is configured', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+ patchEnvironment(fixtures.clerk, fixtures.environment);
+
+ fixtures.clerk.__internal_getOption = vi.fn((key: string) => {
+ if (key === 'appearance') {
+ return { cssLayerName: 'app-clerk' };
+ }
+ return undefined;
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ const cache = getStyleCache(fixtures.clerk);
+ expect(cache).toBeDefined();
+ if (!cache) {
+ return;
+ }
+
+ // Insert a style and read what actually landed in the cache's