diff --git a/.changeset/profile-composed-orgprofile.md b/.changeset/profile-composed-orgprofile.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/profile-composed-orgprofile.md
@@ -0,0 +1,2 @@
+---
+---
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/__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__/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__/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