From 7e8bcd2c6148f40f2bbacf24deabf8013a613726 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 21 May 2026 10:41:48 -0400 Subject: [PATCH 01/24] init --- packages/ui/package.json | 5 + .../composed/OrganizationProfile/APIKeys.tsx | 17 ++ .../composed/OrganizationProfile/Billing.tsx | 53 ++++++ .../composed/OrganizationProfile/General.tsx | 3 + .../composed/OrganizationProfile/Members.tsx | 3 + .../OrganizationProfileProvider.tsx | 76 ++++++++ .../composed/OrganizationProfile/index.tsx | 13 ++ .../ui/src/composed/UserProfile/APIKeys.tsx | 17 ++ .../ui/src/composed/UserProfile/Account.tsx | 3 + .../ui/src/composed/UserProfile/Billing.tsx | 53 ++++++ .../ui/src/composed/UserProfile/Security.tsx | 3 + .../UserProfile/UserProfileProvider.tsx | 73 ++++++++ .../ui/src/composed/UserProfile/index.tsx | 13 ++ .../__tests__/OrganizationProfile.test.tsx | 47 +++++ .../composed/__tests__/UserProfile.test.tsx | 168 ++++++++++++++++++ .../__tests__/context-parity.test.tsx | 72 ++++++++ packages/ui/src/composed/index.ts | 2 + packages/ui/src/composed/stubRouter.ts | 22 +++ packages/ui/src/composed/useBillingRouter.ts | 85 +++++++++ .../elements/ProfileCard/ProfileCardPage.tsx | 20 +-- packages/ui/src/elements/ProfileCard/index.ts | 4 +- packages/ui/src/experimental/index.ts | 1 + packages/ui/tsdown.config.mts | 1 + playground/composed/index.html | 17 ++ playground/composed/package.json | 22 +++ playground/composed/src/App.tsx | 116 ++++++++++++ playground/composed/src/main.tsx | 19 ++ playground/composed/src/vite-env.d.ts | 9 + playground/composed/tsconfig.json | 12 ++ playground/composed/vite.config.ts | 6 + pnpm-lock.yaml | 43 +++++ pnpm-workspace.yaml | 1 + 32 files changed, 988 insertions(+), 11 deletions(-) create mode 100644 packages/ui/src/composed/OrganizationProfile/APIKeys.tsx create mode 100644 packages/ui/src/composed/OrganizationProfile/Billing.tsx create mode 100644 packages/ui/src/composed/OrganizationProfile/General.tsx create mode 100644 packages/ui/src/composed/OrganizationProfile/Members.tsx create mode 100644 packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx create mode 100644 packages/ui/src/composed/OrganizationProfile/index.tsx create mode 100644 packages/ui/src/composed/UserProfile/APIKeys.tsx create mode 100644 packages/ui/src/composed/UserProfile/Account.tsx create mode 100644 packages/ui/src/composed/UserProfile/Billing.tsx create mode 100644 packages/ui/src/composed/UserProfile/Security.tsx create mode 100644 packages/ui/src/composed/UserProfile/UserProfileProvider.tsx create mode 100644 packages/ui/src/composed/UserProfile/index.tsx create mode 100644 packages/ui/src/composed/__tests__/OrganizationProfile.test.tsx create mode 100644 packages/ui/src/composed/__tests__/UserProfile.test.tsx create mode 100644 packages/ui/src/composed/__tests__/context-parity.test.tsx create mode 100644 packages/ui/src/composed/index.ts create mode 100644 packages/ui/src/composed/stubRouter.ts create mode 100644 packages/ui/src/composed/useBillingRouter.ts create mode 100644 packages/ui/src/experimental/index.ts create mode 100644 playground/composed/index.html create mode 100644 playground/composed/package.json create mode 100644 playground/composed/src/App.tsx create mode 100644 playground/composed/src/main.tsx create mode 100644 playground/composed/src/vite-env.d.ts create mode 100644 playground/composed/tsconfig.json create mode 100644 playground/composed/vite.config.ts diff --git a/packages/ui/package.json b/packages/ui/package.json index 041b597c762..c538302ccfe 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/composed/OrganizationProfile/APIKeys.tsx b/packages/ui/src/composed/OrganizationProfile/APIKeys.tsx new file mode 100644 index 00000000000..6f9598127ab --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/APIKeys.tsx @@ -0,0 +1,17 @@ +import { lazy, Suspense } from 'react'; + +import { CardStateProvider } from '../../elements/contexts'; + +const OrganizationAPIKeysPage = lazy(() => + import('../../components/OrganizationProfile/OrganizationAPIKeysPage').then(m => ({ + default: m.OrganizationAPIKeysPage, + })), +); + +export const APIKeys = () => ( + + + + + +); diff --git a/packages/ui/src/composed/OrganizationProfile/Billing.tsx b/packages/ui/src/composed/OrganizationProfile/Billing.tsx new file mode 100644 index 00000000000..47fe8280d76 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/Billing.tsx @@ -0,0 +1,53 @@ +import { lazy, Suspense } from 'react'; + +import { RouteContext } from '../../router/RouteContext'; +import { useBillingRouter } from '../useBillingRouter'; + +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 Billing = () => { + const { router, route } = useBillingRouter(); + + let content: React.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/General.tsx b/packages/ui/src/composed/OrganizationProfile/General.tsx new file mode 100644 index 00000000000..2242af2667b --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/General.tsx @@ -0,0 +1,3 @@ +import { OrganizationGeneralPage } from '../../components/OrganizationProfile/OrganizationGeneralPage'; + +export const General = () => ; diff --git a/packages/ui/src/composed/OrganizationProfile/Members.tsx b/packages/ui/src/composed/OrganizationProfile/Members.tsx new file mode 100644 index 00000000000..65141655bb0 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/Members.tsx @@ -0,0 +1,3 @@ +import { OrganizationMembers } from '../../components/OrganizationProfile/OrganizationMembers'; + +export const Members = () => ; diff --git a/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx b/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx new file mode 100644 index 00000000000..4c7722737bc --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx @@ -0,0 +1,76 @@ +import type { ModuleManager } from '@clerk/shared/moduleManager'; +import { useClerk, useOrganization, useUser } from '@clerk/shared/react'; +import type { EnvironmentResource, OAuthProvider, OAuthScope } from '@clerk/shared/types'; +import React from 'react'; + +import { AppearanceProvider } from '@/ui/customizables/AppearanceContext'; +import { FlowMetadataProvider } from '@/ui/elements/contexts'; +import type { Appearance } from '@/ui/internal/appearance'; +import { RouteContext } from '@/ui/router/RouteContext'; +import { InternalThemeProvider } from '@/ui/styledSystem'; +import { StyleCacheProvider } from '@/ui/styledSystem/StyleCacheProvider'; + +import { EnvironmentProvider } from '../../contexts/EnvironmentContext'; +import { ModuleManagerProvider } from '../../contexts/ModuleManagerContext'; +import { OptionsProvider } from '../../contexts/OptionsContext'; +import { SubscriberTypeContext } from '../../contexts/components/SubscriberType'; +import { OrganizationProfileContext } from '../../contexts/components/OrganizationProfile'; +import { ProfileCardPagePaddingProvider } from '../../elements/ProfileCard'; +import { stubRouter } from '../stubRouter'; + +const stubModuleManager: ModuleManager = { + import: () => Promise.resolve(undefined), +}; + +type OrganizationProfileProviderProps = React.PropsWithChildren<{ + appearance?: Appearance; + additionalOAuthScopes?: Partial>; +}>; + +export const OrganizationProfileProvider = (props: OrganizationProfileProviderProps) => { + const { children, appearance } = props; + const clerk = useClerk(); + const { isLoaded, user } = useUser(); + const { organization } = useOrganization(); + + const environment = (clerk as any).__internal_environment as EnvironmentResource | null | undefined; + + if (!isLoaded || !user || !organization || !environment) { + return null; + } + + const orgProfileCtxValue = { + componentName: 'OrganizationProfile' as const, + mode: 'mounted' as const, + routing: 'hash' as const, + path: undefined, + customPages: [], + }; + + return ( + + + + + + + + + + + {children} + + + + + + + + + + + ); +}; diff --git a/packages/ui/src/composed/OrganizationProfile/index.tsx b/packages/ui/src/composed/OrganizationProfile/index.tsx new file mode 100644 index 00000000000..364b926a3b5 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/index.tsx @@ -0,0 +1,13 @@ +import { APIKeys } from './APIKeys'; +import { Billing } from './Billing'; +import { General } from './General'; +import { Members } from './Members'; +import { OrganizationProfileProvider } from './OrganizationProfileProvider'; + +export const OrganizationProfile = { + Provider: OrganizationProfileProvider, + General, + Members, + Billing, + APIKeys, +}; diff --git a/packages/ui/src/composed/UserProfile/APIKeys.tsx b/packages/ui/src/composed/UserProfile/APIKeys.tsx new file mode 100644 index 00000000000..68c2bc231a7 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/APIKeys.tsx @@ -0,0 +1,17 @@ +import { lazy, Suspense } from 'react'; + +import { CardStateProvider } from '../../elements/contexts'; + +const APIKeysPage = lazy(() => + import('../../components/UserProfile/APIKeysPage').then(m => ({ + default: m.APIKeysPage, + })), +); + +export const APIKeys = () => ( + + + + + +); diff --git a/packages/ui/src/composed/UserProfile/Account.tsx b/packages/ui/src/composed/UserProfile/Account.tsx new file mode 100644 index 00000000000..b0d9720d29f --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Account.tsx @@ -0,0 +1,3 @@ +import { AccountPage } from '../../components/UserProfile/AccountPage'; + +export const Account = () => ; diff --git a/packages/ui/src/composed/UserProfile/Billing.tsx b/packages/ui/src/composed/UserProfile/Billing.tsx new file mode 100644 index 00000000000..1386a89f8e3 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Billing.tsx @@ -0,0 +1,53 @@ +import { lazy, Suspense } from 'react'; + +import { RouteContext } from '../../router/RouteContext'; +import { useBillingRouter } from '../useBillingRouter'; + +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 Billing = () => { + const { router, route } = useBillingRouter(); + + let content: React.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/UserProfile/Security.tsx b/packages/ui/src/composed/UserProfile/Security.tsx new file mode 100644 index 00000000000..53c5966110c --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Security.tsx @@ -0,0 +1,3 @@ +import { SecurityPage } from '../../components/UserProfile/SecurityPage'; + +export const Security = () => ; diff --git a/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx new file mode 100644 index 00000000000..a074a49ca04 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx @@ -0,0 +1,73 @@ +import type { ModuleManager } from '@clerk/shared/moduleManager'; +import { useClerk, useUser } from '@clerk/shared/react'; +import type { EnvironmentResource, OAuthProvider, OAuthScope } from '@clerk/shared/types'; +import React from 'react'; + +import { AppearanceProvider } from '@/ui/customizables/AppearanceContext'; +import { FlowMetadataProvider } from '@/ui/elements/contexts'; +import type { Appearance } from '@/ui/internal/appearance'; +import { RouteContext } from '@/ui/router/RouteContext'; +import { InternalThemeProvider } from '@/ui/styledSystem'; +import { StyleCacheProvider } from '@/ui/styledSystem/StyleCacheProvider'; + +import { EnvironmentProvider } from '../../contexts/EnvironmentContext'; +import { ModuleManagerProvider } from '../../contexts/ModuleManagerContext'; +import { OptionsProvider } from '../../contexts/OptionsContext'; +import { UserProfileContext } from '../../contexts/components/UserProfile'; +import { ProfileCardPagePaddingProvider } from '../../elements/ProfileCard'; +import { stubRouter } from '../stubRouter'; + +const stubModuleManager: ModuleManager = { + import: () => Promise.resolve(undefined), +}; + +type UserProfileProviderProps = React.PropsWithChildren<{ + appearance?: Appearance; + additionalOAuthScopes?: Partial>; +}>; + +export const UserProfileProvider = (props: UserProfileProviderProps) => { + const { children, appearance, additionalOAuthScopes } = props; + const clerk = useClerk(); + const { isLoaded, user } = useUser(); + + const environment = (clerk as any).__internal_environment as EnvironmentResource | null | undefined; + + if (!isLoaded || !user || !environment) { + return null; + } + + const userProfileCtxValue = { + componentName: 'UserProfile' as const, + mode: 'mounted' as const, + routing: 'hash' as const, + path: undefined, + additionalOAuthScopes, + 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..a8f4d43bc11 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/index.tsx @@ -0,0 +1,13 @@ +import { Account } from './Account'; +import { APIKeys } from './APIKeys'; +import { Billing } from './Billing'; +import { Security } from './Security'; +import { UserProfileProvider } from './UserProfileProvider'; + +export const UserProfile = { + Provider: UserProfileProvider, + Account, + Security, + Billing, + APIKeys, +}; 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__/UserProfile.test.tsx b/packages/ui/src/composed/__tests__/UserProfile.test.tsx new file mode 100644 index 00000000000..69018fb5371 --- /dev/null +++ b/packages/ui/src/composed/__tests__/UserProfile.test.tsx @@ -0,0 +1,168 @@ +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 { AccountPage } from '../../components/UserProfile/AccountPage'; +import { SecurityPage } from '../../components/UserProfile/SecurityPage'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('Experimental UserProfile', () => { + beforeEach(() => { + clearFetchCache(); + }); + + describe('Account page', () => { + it('renders profile section', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + + render(, { wrapper }); + screen.getByText('Test User'); + }); + + it('renders email section when enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render(, { wrapper }); + expect(screen.getAllByText(/email address/i).length).toBeGreaterThan(0); + }); + + it('renders phone section 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('renders username section when enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUsername(); + f.withUser({ email_addresses: ['test@clerk.com'], username: 'testuser' }); + }); + + render(, { wrapper }); + screen.getByText('testuser'); + }); + + it('renders connected accounts section 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('hides sections that are disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ first_name: 'Test', last_name: 'User' }); + }); + + const { queryByText } = render(, { wrapper }); + expect(queryByText(/connected accounts/i)).not.toBeInTheDocument(); + }); + + 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(); + }); + + 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 password section when instance is password-based', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withPassword(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([])); + + render(, { wrapper }); + await waitFor(() => screen.getByText(/^password/i)); + }); + + it('renders passkey section when passkeys are enabled', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withPasskey(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([])); + + render(, { wrapper }); + await waitFor(() => screen.getByText(/^passkeys/i)); + }); + + 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)); + }); + + it('renders delete account section when enabled', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: true }); + }); + fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([])); + + render(, { wrapper }); + await waitFor(() => expect(screen.getAllByText(/delete account/i).length).toBeGreaterThan(0)); + }); + + it('hides delete account section when disabled', 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([])); + + render(, { wrapper }); + await waitFor(() => screen.getByText(/active devices/i)); + expect(screen.queryByText(/danger section/i)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/ui/src/composed/__tests__/context-parity.test.tsx b/packages/ui/src/composed/__tests__/context-parity.test.tsx new file mode 100644 index 00000000000..ae57b209c54 --- /dev/null +++ b/packages/ui/src/composed/__tests__/context-parity.test.tsx @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { useEnvironment } from '../../contexts/EnvironmentContext'; +import { useOptions } from '../../contexts/OptionsContext'; +import { useModuleManager } from '../../contexts/ModuleManagerContext'; +import { useFlowMetadata } from '../../elements/contexts'; +import { useRouter } from '../../router'; +import { useAppearance } from '../../customizables/AppearanceContext'; + +const ContextProbe = ({ testId }: { testId: string }) => { + const environment = useEnvironment(); + const options = useOptions(); + const moduleManager = useModuleManager(); + const flowMetadata = useFlowMetadata(); + const router = useRouter(); + const appearance = useAppearance(); + + return ( +
+ {environment ? 'ok' : 'missing'} + {options !== undefined ? 'ok' : 'missing'} + {moduleManager ? 'ok' : 'missing'} + {flowMetadata?.flow || 'missing'} + {router ? 'ok' : 'missing'} + {appearance ? 'ok' : 'missing'} +
+ ); +}; + +describe('Context parity between portal and experimental paths', () => { + describe('UserProfile context chain', () => { + const { createFixtures } = bindCreateFixtures('UserProfile'); + + it('all contexts are available in the portal path', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render(, { wrapper }); + + expect(screen.getByTestId('portal-env')).toHaveTextContent('ok'); + expect(screen.getByTestId('portal-options')).toHaveTextContent('ok'); + expect(screen.getByTestId('portal-module-manager')).toHaveTextContent('ok'); + expect(screen.getByTestId('portal-flow')).toHaveTextContent('UserProfile'); + expect(screen.getByTestId('portal-router')).toHaveTextContent('ok'); + expect(screen.getByTestId('portal-appearance')).toHaveTextContent('ok'); + }); + }); + + describe('OrganizationProfile context chain', () => { + const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + + it('all contexts are available in the portal path', async () => { + const { wrapper } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + render(, { wrapper }); + + expect(screen.getByTestId('portal-env')).toHaveTextContent('ok'); + expect(screen.getByTestId('portal-options')).toHaveTextContent('ok'); + expect(screen.getByTestId('portal-module-manager')).toHaveTextContent('ok'); + expect(screen.getByTestId('portal-flow')).toHaveTextContent('OrganizationProfile'); + expect(screen.getByTestId('portal-router')).toHaveTextContent('ok'); + expect(screen.getByTestId('portal-appearance')).toHaveTextContent('ok'); + }); + }); +}); diff --git a/packages/ui/src/composed/index.ts b/packages/ui/src/composed/index.ts new file mode 100644 index 00000000000..bfbe324d413 --- /dev/null +++ b/packages/ui/src/composed/index.ts @@ -0,0 +1,2 @@ +export { UserProfile } from './UserProfile'; +export { OrganizationProfile } from './OrganizationProfile'; diff --git a/packages/ui/src/composed/stubRouter.ts b/packages/ui/src/composed/stubRouter.ts new file mode 100644 index 00000000000..dbcda38a77d --- /dev/null +++ b/packages/ui/src/composed/stubRouter.ts @@ -0,0 +1,22 @@ +import type { RouteContextValue } from '../router/RouteContext'; + +const noop = () => {}; +const noopAsync = () => Promise.resolve(); + +export const stubRouter: RouteContextValue = { + basePath: '', + startPath: '', + flowStartPath: '', + fullPath: '', + indexPath: '', + currentPath: '', + matches: () => false, + baseNavigate: noopAsync, + navigate: noopAsync, + resolve: (to: string) => new URL(to, window.location.origin), + refresh: noop, + params: {}, + queryString: '', + queryParams: {}, + getMatchData: () => false, +}; diff --git a/packages/ui/src/composed/useBillingRouter.ts b/packages/ui/src/composed/useBillingRouter.ts new file mode 100644 index 00000000000..ea846c40d14 --- /dev/null +++ b/packages/ui/src/composed/useBillingRouter.ts @@ -0,0 +1,85 @@ +import { useState } from 'react'; + +import type { RouteContextValue } from '../router/RouteContext'; +import { stubRouter } from './stubRouter'; + +type BillingRoute = + | { page: 'billing' } + | { page: 'plans' } + | { page: 'statement'; statementId: string } + | { page: 'payment-attempt'; paymentAttemptId: string }; + +function resolveNavigation(_currentRoute: BillingRoute, to: string): BillingRoute { + let path = to; + while (path.startsWith('../')) { + path = path.slice(3); + } + + if (!path || path === '/') { + return { page: 'billing' }; + } + + if (path === 'plans') { + return { page: 'plans' }; + } + + const statementMatch = path.match(/^statement\/(.+)$/); + if (statementMatch) { + return { page: 'statement', statementId: statementMatch[1] }; + } + + const paymentMatch = path.match(/^payment-attempt\/(.+)$/); + if (paymentMatch) { + return { page: 'payment-attempt', paymentAttemptId: paymentMatch[1] }; + } + + return { page: 'billing' }; +} + +function pathFromRoute(route: BillingRoute): string { + switch (route.page) { + case 'plans': + return 'billing/plans'; + case 'statement': + return `billing/statement/${route.statementId}`; + case 'payment-attempt': + return `billing/payment-attempt/${route.paymentAttemptId}`; + default: + return 'billing'; + } +} + +function paramsFromRoute(route: BillingRoute): Record { + switch (route.page) { + case 'statement': + return { statementId: route.statementId }; + case 'payment-attempt': + return { paymentAttemptId: route.paymentAttemptId }; + default: + return {}; + } +} + +export function useBillingRouter(): { router: RouteContextValue; route: BillingRoute } { + const [route, setRoute] = useState({ page: 'billing' }); + const [queryParams, setQueryParams] = useState>({}); + + const router: RouteContextValue = { + ...stubRouter, + currentPath: pathFromRoute(route), + params: paramsFromRoute(route), + queryParams, + queryString: new URLSearchParams(queryParams).toString(), + navigate: async (to: string, options?: { searchParams?: URLSearchParams }) => { + const newRoute = resolveNavigation(route, to); + setRoute(newRoute); + if (options?.searchParams) { + setQueryParams(Object.fromEntries(options.searchParams.entries())); + } else if (newRoute.page !== route.page) { + setQueryParams({}); + } + }, + }; + + return { router, route }; +} diff --git a/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx b/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx index ecc0d016a49..1a599b22292 100644 --- a/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx +++ b/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx @@ -1,8 +1,12 @@ -import type { PropsWithChildren } from 'react'; +import { createContext, type PropsWithChildren, useContext } from 'react'; import { Col } from '../../customizables'; import { mqu } from '../../styledSystem'; +const ProfileCardPagePaddingContext = createContext(true); + +export const ProfileCardPagePaddingProvider = ProfileCardPagePaddingContext.Provider; + type ProfileCardPageProps = PropsWithChildren<{ /** * Whether to apply the standard per-page padding. @@ -17,21 +21,17 @@ type ProfileCardPageProps = PropsWithChildren<{ bleeding?: boolean; }>; -/** - * Per-page padding wrapper rendered inside `ProfileCardContent` - * - * Each routed page inside `UserProfile` / `OrganizationProfile` should wrap its content - * in this component - */ -export const ProfileCardPage = ({ children, padding = true, bleeding = false }: ProfileCardPageProps) => { - if (!padding && !bleeding) { +export const ProfileCardPage = ({ children, padding, bleeding = false }: ProfileCardPageProps) => { + const defaultPadding = useContext(ProfileCardPagePaddingContext); + const shouldPad = padding ?? defaultPadding; + if (!shouldPad && !bleeding) { return <>{children}; } return ( ({ - ...(padding && { + ...(shouldPad && { paddingTop: theme.space.$7, paddingBottom: theme.space.$7, paddingInlineStart: theme.space.$8, diff --git a/packages/ui/src/elements/ProfileCard/index.ts b/packages/ui/src/elements/ProfileCard/index.ts index 84df2ddd56e..abe54826f3a 100644 --- a/packages/ui/src/elements/ProfileCard/index.ts +++ b/packages/ui/src/elements/ProfileCard/index.ts @@ -1,7 +1,9 @@ import { ProfileCardContent } from './ProfileCardContent'; -import { ProfileCardPage } from './ProfileCardPage'; +import { ProfileCardPage, ProfileCardPagePaddingProvider } from './ProfileCardPage'; import { ProfileCardRoot } from './ProfileCardRoot'; +export { ProfileCardPagePaddingProvider }; + export const ProfileCard = { Root: ProfileCardRoot, Content: ProfileCardContent, diff --git a/packages/ui/src/experimental/index.ts b/packages/ui/src/experimental/index.ts new file mode 100644 index 00000000000..d28965f8ff4 --- /dev/null +++ b/packages/ui/src/experimental/index.ts @@ -0,0 +1 @@ +export { UserProfile, OrganizationProfile } from '../composed'; diff --git a/packages/ui/tsdown.config.mts b/packages/ui/tsdown.config.mts index aba6fd3f133..ba2f1953525 100644 --- a/packages/ui/tsdown.config.mts +++ b/packages/ui/tsdown.config.mts @@ -39,6 +39,7 @@ export default defineConfig(({ watch }) => { './src/internal/index.ts', './src/themes/index.ts', './src/themes/experimental.ts', + './src/experimental/index.ts', ], outDir: './dist', unbundle: true, diff --git a/playground/composed/index.html b/playground/composed/index.html new file mode 100644 index 00000000000..2399dae9217 --- /dev/null +++ b/playground/composed/index.html @@ -0,0 +1,17 @@ + + + + + + Composed UserProfile Playground + + + +
+ + + diff --git a/playground/composed/package.json b/playground/composed/package.json new file mode 100644 index 00000000000..072b97a05b9 --- /dev/null +++ b/playground/composed/package.json @@ -0,0 +1,22 @@ +{ + "name": "playground-composed", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "dependencies": { + "@clerk/react": "workspace:*", + "@clerk/ui": "workspace:*", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/react": "18.3.28", + "@types/react-dom": "18.3.7", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "5.8.3", + "vite": "^6.0.0" + } +} diff --git a/playground/composed/src/App.tsx b/playground/composed/src/App.tsx new file mode 100644 index 00000000000..3598749ef4a --- /dev/null +++ b/playground/composed/src/App.tsx @@ -0,0 +1,116 @@ +import { Show, SignInButton, UserButton } from '@clerk/react'; +import { UserProfile, OrganizationProfile } from '@clerk/ui/experimental'; +import { useState } from 'react'; + +type ProfileType = 'user' | 'organization'; +type UserTab = 'account' | 'security' | 'billing' | 'api-keys'; +type OrgTab = 'general' | 'members' | 'billing' | 'api-keys'; + +export function App() { + const [profileType, setProfileType] = useState('user'); + const [userTab, setUserTab] = useState('account'); + const [orgTab, setOrgTab] = useState('general'); + + return ( +
+ +
+

Experimental Composed Profiles

+ +
+ +
+ + +
+ + {profileType === 'user' && ( + + + {userTab === 'account' && } + {userTab === 'security' && } + {userTab === 'billing' && } + {userTab === 'api-keys' && } + + )} + + {profileType === 'organization' && ( + + + {orgTab === 'general' && } + {orgTab === 'members' && } + {orgTab === 'billing' && } + {orgTab === 'api-keys' && } + + )} + + } + > +

Experimental Composed Profiles Playground

+

Sign in to test the composed UserProfile and OrganizationProfile components.

+ +
+
+ ); +} + +function TabBar({ tabs, active, onChange }: { tabs: T[]; active: T; onChange: (tab: T) => void }) { + return ( +
+ {tabs.map(tab => ( + + ))} +
+ ); +} diff --git a/playground/composed/src/main.tsx b/playground/composed/src/main.tsx new file mode 100644 index 00000000000..7a7d6ea12d1 --- /dev/null +++ b/playground/composed/src/main.tsx @@ -0,0 +1,19 @@ +import { ClerkProvider } from '@clerk/react'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { App } from './App'; + +const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; + +if (!publishableKey) { + throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY in .env.local'); +} + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/playground/composed/src/vite-env.d.ts b/playground/composed/src/vite-env.d.ts new file mode 100644 index 00000000000..91af686e054 --- /dev/null +++ b/playground/composed/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_CLERK_PUBLISHABLE_KEY: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/playground/composed/tsconfig.json b/playground/composed/tsconfig.json new file mode 100644 index 00000000000..f003d97f308 --- /dev/null +++ b/playground/composed/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/playground/composed/vite.config.ts b/playground/composed/vite.config.ts new file mode 100644 index 00000000000..fabde1a8f5e --- /dev/null +++ b/playground/composed/vite.config.ts @@ -0,0 +1,6 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 557ff85e7b7..b1fd2b3d912 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1085,6 +1085,37 @@ importers: specifier: ^3.2.4 version: 3.2.4(typescript@5.8.3) + playground/composed: + dependencies: + '@clerk/react': + specifier: workspace:* + version: link:../../packages/react + '@clerk/ui': + specifier: workspace:* + version: link:../../packages/ui + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: 18.3.28 + version: 18.3.28 + '@types/react-dom': + specifier: 18.3.7 + version: 18.3.7(@types/react@18.3.28) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3)) + typescript: + specifier: 5.8.3 + version: 5.8.3 + vite: + specifier: 7.3.3 + version: 7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3) + packages: '@0no-co/graphql.web@1.2.0': @@ -20623,6 +20654,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-react@4.7.0(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + '@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3))(vue@3.5.33(typescript@5.8.3))': dependencies: '@babel/core': 7.29.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c3cdc3b057d..708bbe1ba24 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - packages/* + - playground/* catalogs: peer-react: From f5f8e2046d1f06c0749c5ecb035e509585382997 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 21 May 2026 14:57:48 -0400 Subject: [PATCH 02/24] wip --- packages/ui/src/composed/stubRouter.ts | 21 +++++++++++++++++--- packages/ui/src/composed/useBillingRouter.ts | 7 +++++++ packages/ui/src/hooks/useSafeState.ts | 1 + 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/composed/stubRouter.ts b/packages/ui/src/composed/stubRouter.ts index dbcda38a77d..b334c193a78 100644 --- a/packages/ui/src/composed/stubRouter.ts +++ b/packages/ui/src/composed/stubRouter.ts @@ -1,7 +1,14 @@ import type { RouteContextValue } from '../router/RouteContext'; const noop = () => {}; -const noopAsync = () => Promise.resolve(); + +function isExternalUrl(to: string): boolean { + try { + return new URL(to).origin !== window.location.origin; + } catch { + return false; + } +} export const stubRouter: RouteContextValue = { basePath: '', @@ -11,8 +18,16 @@ export const stubRouter: RouteContextValue = { indexPath: '', currentPath: '', matches: () => false, - baseNavigate: noopAsync, - navigate: noopAsync, + baseNavigate: async (toURL: URL) => { + if (toURL.origin !== window.location.origin) { + window.location.href = toURL.href; + } + }, + navigate: async (to: string) => { + if (isExternalUrl(to)) { + window.location.href = to; + } + }, resolve: (to: string) => new URL(to, window.location.origin), refresh: noop, params: {}, diff --git a/packages/ui/src/composed/useBillingRouter.ts b/packages/ui/src/composed/useBillingRouter.ts index ea846c40d14..59bc4714dd4 100644 --- a/packages/ui/src/composed/useBillingRouter.ts +++ b/packages/ui/src/composed/useBillingRouter.ts @@ -71,6 +71,13 @@ export function useBillingRouter(): { router: RouteContextValue; route: BillingR queryParams, queryString: new URLSearchParams(queryParams).toString(), navigate: async (to: string, options?: { searchParams?: URLSearchParams }) => { + try { + const url = new URL(to); + if (url.origin !== window.location.origin) { + window.location.href = to; + return; + } + } catch {} const newRoute = resolveNavigation(route, to); setRoute(newRoute); if (options?.searchParams) { diff --git a/packages/ui/src/hooks/useSafeState.ts b/packages/ui/src/hooks/useSafeState.ts index cba72ee5eec..7d3641738e9 100644 --- a/packages/ui/src/hooks/useSafeState.ts +++ b/packages/ui/src/hooks/useSafeState.ts @@ -13,6 +13,7 @@ export function useSafeState(initialState?: S | (() => S)) { const isMountedRef = React.useRef(true); React.useEffect(() => { + isMountedRef.current = true; return () => { isMountedRef.current = false; }; From e95ff5a09644335a4ca464bcf726340017a4bdbf Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 21 May 2026 16:56:00 -0400 Subject: [PATCH 03/24] fix --- .../components/OrganizationProfile/OrganizationBillingPage.tsx | 2 +- .../components/OrganizationProfile/OrganizationGeneralPage.tsx | 2 +- .../src/components/OrganizationProfile/OrganizationMembers.tsx | 1 + packages/ui/src/components/UserProfile/APIKeysPage.tsx | 1 + packages/ui/src/components/UserProfile/AccountPage.tsx | 2 +- packages/ui/src/components/UserProfile/BillingPage.tsx | 2 +- packages/ui/src/components/UserProfile/SecurityPage.tsx | 2 +- 7 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx index fe8018cd709..bbf10255b39 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx @@ -28,7 +28,7 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => { ({ gap: t.space.$8, color: t.colors.$colorForeground })} + sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} > { ({ gap: t.space.$8 })} + sx={t => ({ gap: t.space.$8, isolation: 'isolate' })} > { { { ({ gap: t.space.$8, color: t.colors.$colorForeground })} + sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} > { ({ gap: t.space.$8, color: t.colors.$colorForeground })} + sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} > { ({ gap: t.space.$8 })} + sx={t => ({ gap: t.space.$8, isolation: 'isolate' })} > Date: Fri, 22 May 2026 09:17:01 -0400 Subject: [PATCH 04/24] Create COMPOSED_API_PLAN.md --- packages/ui/COMPOSED_API_PLAN.md | 753 +++++++++++++++++++++++++++++++ 1 file changed, 753 insertions(+) create mode 100644 packages/ui/COMPOSED_API_PLAN.md diff --git a/packages/ui/COMPOSED_API_PLAN.md b/packages/ui/COMPOSED_API_PLAN.md new file mode 100644 index 00000000000..e3fdb1cfe7d --- /dev/null +++ b/packages/ui/COMPOSED_API_PLAN.md @@ -0,0 +1,753 @@ +# Composed Profile API — Design Plan + +## Context + +The `@clerk/ui/experimental` export provides composable profile subcomponents that render outside Clerk's portal infrastructure. The current API uses named components (`UserProfile.Account`, `UserProfile.Security`) that render full page components. We're replacing this with a `Page`/`Section` API that gives consumers full compositional control: omit sections, reorder them, and inject custom content between them. + +## API + +### Import + +```tsx +import { UserProfile, OrganizationProfile } from '@clerk/ui/experimental'; +``` + +### Basic usage — full defaults + +```tsx + + + + + + +``` + +Each `Page` with no children renders the **full default page**: header, error alert, all built-in sections (respecting environment flags). + +**Rendered output for ``:** + +``` +┌──────────────────────────────────┐ +│ Account (h2) │ +│ │ +│ [Card.Alert - errors show here] │ +│ │ +│ ┌─ Profile Section ────────────┐ │ +│ │ Avatar, name, update button │ │ +│ └──────────────────────────────┘ │ +│ ┌─ Username Section ───────────┐ │ +│ │ (if username enabled) │ │ +│ └──────────────────────────────┘ │ +│ ┌─ Email Section ──────────────┐ │ +│ │ (if email enabled) │ │ +│ └──────────────────────────────┘ │ +│ ┌─ Phone Section ──────────────┐ │ +│ │ (if phone enabled) │ │ +│ └──────────────────────────────┘ │ +│ ┌─ Connected Accounts ─────────┐ │ +│ │ (if social providers) │ │ +│ └──────────────────────────────┘ │ +│ ┌─ Enterprise Accounts ────────┐ │ +│ │ (if enterprise SSO enabled) │ │ +│ └──────────────────────────────┘ │ +│ ┌─ Web3 Wallets ──────────────┐ │ +│ │ (if web3 enabled) │ │ +│ └──────────────────────────────┘ │ +└──────────────────────────────────┘ +``` + +### Section-level composition + +```tsx + + + + + + +``` + +When children are passed, the `Page` still renders the **header** and **Card.Alert** (error display), but children control the section layout below. No ProfileCard.Page padding wrapper. + +**Rendered output:** + +``` +┌──────────────────────────────────┐ +│ Account (h2) │ +│ │ +│ [Card.Alert - errors show here] │ +│ │ +│ ┌─ Profile Section ────────────┐ │ +│ │ Avatar, name, update button │ │ +│ └──────────────────────────────┘ │ +│ ┌─ Email Section ──────────────┐ │ +│ │ Email list, add button │ │ +│ └──────────────────────────────┘ │ +└──────────────────────────────────┘ +``` + +Header and error alert are always present. No phone, username, connected accounts, web3 sections — only what's declared. + +### Custom content injection + +```tsx + + + +
Verify your email to unlock features
+ + +
+
+``` + +**Rendered output:** + +``` +┌──────────────────────────────────┐ +│ Account (h2) │ +│ │ +│ [Card.Alert - errors show here] │ +│ │ +│ ┌─ Profile Section ────────────┐ │ +│ │ Avatar, name, update button │ │ +│ └──────────────────────────────┘ │ +│ │ +│ ┌─ my-banner ──────────────────┐ │ +│ │ Verify your email to unlock │ │ +│ │ features │ │ +│ └──────────────────────────────┘ │ +│ │ +│ ┌─ Email Section ──────────────┐ │ +│ │ Email list, add button │ │ +│ └──────────────────────────────┘ │ +│ ┌─ Phone Section ──────────────┐ │ +│ │ Phone list, add button │ │ +│ └──────────────────────────────┘ │ +└──────────────────────────────────┘ +``` + +Header and Card.Alert always present. Children render in declaration order below. `Section` resolves to built-in UI. Everything else passes through as-is. + +### Custom pages + +```tsx + + + + + + +``` + +`Page` with `title` (no `id`) renders a custom page. Children are the page content. `title` and `id` are mutually exclusive. + +**Rendered output:** + +``` +Page 1: +┌──────────────────────────────────┐ +│ Account │ +│ [full default account page] │ +└──────────────────────────────────┘ + +Page 2: +┌──────────────────────────────────┐ +│ [MyPreferencesPanel renders] │ +└──────────────────────────────────┘ +``` + +### OrganizationProfile + +```tsx + + + + + + +``` + +Section composition on the general page: + +```tsx + + + + + + +``` + +--- + +## Behavior Rules + +### 1. No children = passthrough to existing component + +`` renders the existing `AccountPage` component directly. The Page adds nothing — `AccountPage` already provides its own header, `Card.Alert`, `CardStateProvider`, and all sections with environment guards. This is a zero-change passthrough. + +### 2. Any children = Page provides chrome + children control sections + +If you pass ANY children, the Page renders: + +- `CardStateProvider` (shared error state) +- `PageContext.Provider` (tells Section which page it's inside) +- Header (localized page title, styled as h2) +- `Card.Alert` (error display) +- Flex column with standard gap (`space.$8`) +- Children (Sections + custom content) + +The existing page component is NOT rendered. The Page component builds the chrome from scratch. + +### 3. Environment guards always respected + +`
` renders nothing (`null`) if `email_address` is not enabled in the Clerk dashboard. Guards are enforced in the `Section` wrapper, matching the logic currently in the parent page component. + +### 4. Loose section typing + +`Section` accepts any valid section ID from a flat union. If the ID doesn't match the parent page's type, it renders nothing (`null`). No runtime error, no DOM output. + +```tsx +// Renders nothing — 'password' isn't an account section + + + +``` + +### 5. Section resolution via PageContext + +`Page` provides a `PageContext` with the page ID. `Section` reads this context to look up the correct component from the section registry. This resolves ambiguity where the same section ID (e.g., `profile`) maps to different components depending on the page type. + +```tsx +// Inside : Section id="profile" → UserProfileSection +// Inside : Section id="profile" → OrganizationProfileSection +``` + +### 6. Atomic pages + +These pages don't support section composition — children are ignored: + +| Page ID | Reason | +| ---------- | ------------------------------------------------------------ | +| `members` | Single component with tabs, shared pagination, role fetching | +| `api-keys` | Single component, no discrete sections | + +### 7. Billing page — composable with managed navigation + +`` supports section composition AND manages sub-page navigation internally. The Page always provides the billing router (`useBillingRouter`). When the user is on the main view, sections render. When a section triggers navigation (e.g., "Switch plans"), the Page swaps the entire view to the sub-page (plans, statement detail, payment detail). Back navigation returns to the section view. + +**Default (no children) — passthrough to existing BillingPage with tabs:** + +``` + + +Renders the existing BillingPage (with tabs + sub-page navigation): +┌──────────────────────────────────────┐ +│ Billing (h2) │ +│ │ +│ [Subscriptions] [Statements] [Payments] +│ │ +│ ┌─ Current Plan ──────────────────┐ │ +│ │ Pro Plan - $20/mo │ │ +│ │ [Switch plans] │ │ +│ └─────────────────────────────────┘ │ +│ ┌─ Payment Methods ───────────────┐ │ +│ │ Visa •••• 4242 │ │ +│ └─────────────────────────────────┘ │ +└──────────────────────────────────────┘ +``` + +**Custom sections — no tabs, sections stack vertically. Consumer can add their own tab UI if desired:** + +```tsx + +
+
+ +``` + +``` +Renders: +┌──────────────────────────────────────┐ +│ Billing (h2) │ +│ │ +│ ┌─ Subscriptions ─────────────────┐ │ +│ │ Pro Plan - $20/mo │ │ +│ │ [Switch plans] │ │ +│ └─────────────────────────────────┘ │ +│ ┌─ Payment Methods ───────────────┐ │ +│ │ Visa •••• 4242 │ │ +│ └─────────────────────────────────┘ │ +└──────────────────────────────────────┘ +``` + +**Sub-page navigation — managed by the Page:** + +``` +User clicks "Switch plans" in subscriptions → +Section calls navigate('plans') → +useBillingRouter updates route to { page: 'plans' } → +Page swaps entire view to PlansPage: + +┌──────────────────────────────────────┐ +│ ← Plans (h2) │ +│ │ +│ ┌─ Pricing Table ─────────────────┐ │ +│ │ Free Pro Enterprise │ │ +│ │ $0/mo $20/mo $50/mo │ │ +│ └─────────────────────────────────┘ │ +└──────────────────────────────────────┘ + +User clicks back → +useBillingRouter resets to { page: 'billing' } → +Page swaps back to sections view +``` + +Same pattern for statement detail and payment detail — sections trigger navigation, Page manages the view swap. + +**Billing section IDs:** + +| Section ID | Component | Router dependency | +| ---------------- | --------------------- | ------------------------------------------------------------ | +| `subscriptions` | `SubscriptionsList` | `navigate('plans')` — triggers sub-page swap | +| `paymentMethods` | `PaymentMethods` | None — fully standalone | +| `statements` | `StatementsList` | `navigate('statement/${id}')` — triggers sub-page swap | +| `payments` | `PaymentAttemptsList` | `navigate('payment-attempt/${id}')` — triggers sub-page swap | + +### 8. Page-level CardState + +When Page has children, it provides a shared `CardStateProvider`. Sections that call `useCardState()` write errors to this shared state, displayed in the `Card.Alert` the Page renders. When Page has no children (passthrough), the existing page component manages its own `CardStateProvider`. + +### 9. Custom page headers + +Custom pages (``) render a header with the title string, styled identically to built-in page headers (h2 variant). Built-in page headers use localization keys. + +### 10. Appearance prop + +`Provider` accepts an `appearance` prop for visual customization, passed through to `AppearanceProvider`. Same as current implementation. + +--- + +## Section Registry + +### UserProfile — Account page sections + +| Section ID | Component | Environment guard | Props injected by wrapper | +| -------------------- | --------------------------- | ------------------------------------------- | -------------------------------------------- | +| `profile` | `UserProfileSection` | none | none | +| `username` | `UsernameSection` | `attributes.username?.enabled` | `isImmutable` | +| `emails` | `EmailsSection` | `attributes.email_address?.enabled` | `shouldAllowCreation`, `shouldAllowDeletion` | +| `phone` | `PhoneSection` | `attributes.phone_number?.enabled` | `shouldAllowCreation`, `shouldAllowDeletion` | +| `connectedAccounts` | `ConnectedAccountsSection` | social providers exist with `enabled: true` | `shouldAllowCreation` | +| `enterpriseAccounts` | `EnterpriseAccountsSection` | `enterpriseSSO.enabled` | none | +| `web3` | `Web3Section` | `attributes.web3_wallet?.enabled` | `shouldAllowCreation` | + +Props are computed from `useEnvironment()` and `useUserProfileContext()` inside the Section wrapper. + +### UserProfile — Security page sections + +| Section ID | Component | Environment guard | Props injected by wrapper | +| --------------- | ---------------------- | -------------------------------------------------------- | ------------------------- | +| `password` | `PasswordSection` | `instanceIsPasswordBased` | none | +| `passkeys` | `PasskeySection` | passkeys enabled AND `shouldAllowIdentificationCreation` | none | +| `mfa` | `MfaSection` | `getSecondFactors(attributes).length > 0` | none | +| `activeDevices` | `ActiveDevicesSection` | none (always rendered) | none | +| `delete` | `DeleteSection` | `user.deleteSelfEnabled` | none | + +### OrganizationProfile — General page sections + +| Section ID | Component | Environment guard | Wrapper extras | +| ---------- | ---------------------------- | ---------------------------------------------------------- | -------------------------------------------------------- | +| `profile` | `OrganizationProfileSection` | none | none | +| `domains` | `OrganizationDomainsSection` | `organizationSettings.domains.enabled` | Wrapped in `` | +| `leave` | `OrganizationLeaveSection` | none | none | +| `delete` | `OrganizationDeleteSection` | `org:sys_profile:delete` permission + `adminDeleteEnabled` | none (guards are internal) | + +--- + +## Type Definitions + +```ts +// --- Page IDs --- + +type UserProfilePageId = 'account' | 'security' | 'billing' | 'api-keys'; +type OrganizationProfilePageId = 'general' | 'members' | 'billing' | 'api-keys'; + +// --- Section IDs --- + +type UserProfileSectionId = + // Account + | 'profile' + | 'username' + | 'emails' + | 'phone' + | 'connectedAccounts' + | 'enterpriseAccounts' + | 'web3' + // Security + | 'password' + | 'passkeys' + | 'mfa' + | 'activeDevices' + | 'delete' + // Billing + | 'subscriptions' + | 'paymentMethods' + | 'statements' + | 'payments'; + +type OrganizationProfileSectionId = + // General + | 'profile' + | 'domains' + | 'leave' + | 'delete' + // Billing (same IDs as UserProfile) + | 'subscriptions' + | 'paymentMethods' + | 'statements' + | 'payments'; + +// --- Component Props --- + +type PageProps = + | { id: UserProfilePageId; title?: never; children?: React.ReactNode } + | { id?: never; title: string; children: React.ReactNode }; + +type SectionProps = { + id: UserProfileSectionId; // or OrganizationProfileSectionId for org +}; +``` + +--- + +## Implementation + +### `Section` wrapper (conceptual) + +Each Section wrapper handles: environment guard, prop computation, and delegation to the existing component. + +```tsx +// Simplified example for the 'emails' section +function EmailsSectionWrapper() { + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext(); + + // Environment guard — matches AccountPage line 25 + if (!attributes.email_address?.enabled) { + return null; + } + + const isImmutable = immutableAttributes.has('email_address'); + + return ( + + ); +} +``` + +The actual `Section` component uses a registry to look up the right wrapper: + +```tsx +const userAccountSections: Record = { + profile: ProfileSectionWrapper, + username: UsernameSectionWrapper, + emails: EmailsSectionWrapper, + phone: PhoneSectionWrapper, + connectedAccounts: ConnectedAccountsSectionWrapper, + enterpriseAccounts: EnterpriseAccountsSectionWrapper, + web3: Web3SectionWrapper, +}; + +const userSecuritySections: Record = { + password: PasswordSectionWrapper, + passkeys: PasskeySectionWrapper, + mfa: MfaSectionWrapper, + activeDevices: ActiveDevicesSectionWrapper, + delete: DeleteSectionWrapper, +}; +``` + +### `Page` component (conceptual) + +```tsx +function UserProfilePage({ id, title, children }: PageProps) { + // Custom page — just render children + if (title) { + return <>{children}; + } + + // Atomic pages — ignore children, render full component + if (id === 'api-keys') { + return ; + } + if (id === 'members') { + return ; // (org only) + } + + // Billing — composable with managed sub-page navigation + if (id === 'billing') { + const { router, route } = useBillingRouter(); + + // Sub-page active — Page takes over the whole view + if (route.page !== 'billing') { + return ( + + + + ); + } + + // Main billing view — section composition + return ( + + + + + {children ?? } + + + ); + } + + // Composable pages (account, security, general) + if (children) { + // Page provides chrome; children control section layout + return ( + + + ({ gap: t.space.$8 })}> + + + {children} + + + + ); + } + + // No children — passthrough to existing page component + // AccountPage/SecurityPage/etc. already have their own header, alert, CardState + return ; +} +``` + +### `DefaultPageRenderer` — renders the full default page + +When no children are passed, this renders the existing page component as-is (AccountPage, SecurityPage, OrganizationGeneralPage). This is what we have today — zero behavioral change for the simple case. + +```tsx +function DefaultPageRenderer({ id }: { id: string }) { + switch (id) { + case 'account': + return ; + case 'security': + return ; + case 'general': + return ; + default: + return null; + } +} +``` + +--- + +## File Changes + +### New files + +| File | Purpose | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------ | +| `src/composed/UserProfile/Page.tsx` | `UserProfile.Page` component with section registry, default rendering, children detection | +| `src/composed/UserProfile/Section.tsx` | `UserProfile.Section` component — looks up wrapper from registry, renders built-in section | +| `src/composed/UserProfile/sectionWrappers.tsx` | Section wrapper components that compute props + guards for each section | +| `src/composed/OrganizationProfile/Page.tsx` | `OrganizationProfile.Page` — same pattern | +| `src/composed/OrganizationProfile/Section.tsx` | `OrganizationProfile.Section` | +| `src/composed/OrganizationProfile/sectionWrappers.tsx` | Org section wrappers | + +### Modified files + +| File | Change | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `src/composed/UserProfile/index.tsx` | Replace `.Account`/`.Security`/`.Billing`/`.APIKeys` with `.Page` + `.Section` | +| `src/composed/OrganizationProfile/index.tsx` | Replace `.General`/`.Members`/`.Billing`/`.APIKeys` with `.Page` + `.Section` | +| `src/components/OrganizationProfile/OrganizationGeneralPage.tsx` | Export the four private section components | +| `playground/composed/src/App.tsx` | Update to use new Page/Section API | + +### Deleted files + +| File | Reason | +| ---------------------------------------------- | ------------------------------------------------------------------ | +| `src/composed/UserProfile/Account.tsx` | Replaced by `Page` + section wrappers | +| `src/composed/UserProfile/Security.tsx` | Replaced by `Page` + section wrappers | +| `src/composed/UserProfile/Billing.tsx` | Replaced by billing logic inside `Page` + billing section wrappers | +| `src/composed/OrganizationProfile/General.tsx` | Replaced by `Page` + section wrappers | +| `src/composed/OrganizationProfile/Members.tsx` | Replaced by `Page` (atomic, rendered directly) | +| `src/composed/OrganizationProfile/Billing.tsx` | Replaced by billing logic inside `Page` + billing section wrappers | + +### Kept as-is + +| File | Reason | +| ------------------------------------------------------------------ | ----------------------------------------------------- | +| `src/composed/UserProfile/APIKeys.tsx` | API Keys is atomic — `Page` delegates to this | +| `src/composed/OrganizationProfile/APIKeys.tsx` | Same | +| `src/composed/useBillingRouter.ts` | Still needed for billing sub-navigation inside `Page` | +| `src/composed/stubRouter.ts` | Still needed for non-billing pages | +| `src/composed/UserProfile/UserProfileProvider.tsx` | Provider unchanged | +| `src/composed/OrganizationProfile/OrganizationProfileProvider.tsx` | Provider unchanged | + +--- + +## Compound Export Shape + +```tsx +// src/composed/UserProfile/index.tsx +export const UserProfile = { + Provider: UserProfileProvider, + Page: UserProfilePage, + Section: UserProfileSection, +}; + +// src/composed/OrganizationProfile/index.tsx +export const OrganizationProfile = { + Provider: OrganizationProfileProvider, + Page: OrganizationProfilePage, + Section: OrganizationProfileSection, +}; +``` + +--- + +## Full Example — Everything Together + +```tsx +import { UserProfile, OrganizationProfile } from '@clerk/ui/experimental'; + +function MyApp() { + return ( +
+ {/* Tab: Profile */} + + + + + + + + + + {/* Tab: Security — full defaults */} + + + + + {/* Tab: Organization */} + + + + + + + + +
+ ); +} +``` + +--- + +## Known Issues & Risks + +### 1. Guard logic duplication + +Section wrappers must replicate guard logic that currently lives in parent page components. Two sources of truth. + +**Examples:** + +- `DeleteSection` renders unconditionally — the guard `user.deleteSelfEnabled` lives only in `SecurityPage.tsx:25`. The section wrapper must add this guard. +- `PasskeySection` visibility depends on `shouldAllowIdentificationCreation` from `useUserProfileContext()` — computed in `SecurityPage.tsx:23`, not in the section itself. +- AccountPage computes `isEmailImmutable`, `isPhoneImmutable`, `isUsernameImmutable` from `useUserProfileContext().immutableAttributes` and passes derived props (`shouldAllowCreation`, `shouldAllowDeletion`) to child sections. + +**Risk:** If a guard changes in the page component (portal path), the section wrapper (composed path) drifts silently. No shared code, no compile-time check. + +**Mitigation options:** + +- Extract shared `useSectionConfig()` hooks that both the page component and the section wrapper call. +- Move guards into the section components themselves (bigger refactor, changes portal path behavior). + +### 2. CardState scoping is inconsistent across sections + +Sections fall into three categories, each with different error display behavior: + +| Category | Sections | Error behavior | +| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| **Self-contained** (own `withCardStateProvider` + own `Card.Alert`) | `ConnectedAccountsSection`, `Web3Section`, `EnterpriseAccountsSection` | Errors display inside the section. Page-level `Card.Alert` never sees them. | +| **Parent-dependent** (no provider, calls `useCardState()`) | `EmailsSection`, `PhoneSection`, `MfaSection` | Errors bubble to the nearest `CardStateProvider` — currently the page's. | +| **No card state** | `UserProfileSection`, `UsernameSection`, `PasswordSection`, `PasskeySection`, `ActiveDevicesSection`, `DeleteSection` | No error interaction at the section level. | + +**The problem:** When `Page` provides chrome (children mode), it wraps everything in a `CardStateProvider` + renders `Card.Alert`. This works for parent-dependent sections (emails, phone, mfa) — their errors show in the page alert. But self-contained sections (connected accounts, web3) have their own provider, so errors display in duplicate locations OR only inside the section. + +**Worse:** If a parent-dependent section like `EmailsSection` is rendered WITHOUT a parent `CardStateProvider` (e.g., directly under `Provider` without a `Page`), `useCardState()` will throw. + +**Decision needed:** Should we require all sections to be self-contained? Or enforce that sections only render inside a `Page`? + +### 3. Org section components are private + +All four section components in `OrganizationGeneralPage.tsx` are unexported module-private `const`s: + +- `OrganizationProfileSection` (line 88) +- `OrganizationDomainsSection` (line 137) +- `OrganizationLeaveSection` (line 186) +- `OrganizationDeleteSection` (line 232) + +We need to export them for the section registry. This changes the module's public API surface. + +### 4. Org leave/delete forms depend on navigation callback + +`ActionConfirmationPage.tsx:22` reads `useOrganizationProfileContext().navigateAfterLeaveOrganization` — a callback that navigates away after the user leaves or deletes an org. In the portal path, this navigates to a different route. In the composed path, there's no route to navigate to. + +**Decision needed:** What should happen after leaving/deleting an org in the composed path? Callback prop on `Provider`? No-op? The current `OrganizationProfileProvider` doesn't provide `navigateAfterLeaveOrganization`. + +### 5. `useUserProfileContext()` is expensive + +The hook (`contexts/components/UserProfile.ts`) calls `useSubscription()`, `useStatements()`, and computes `pages` (navbar routes) on every render. Every section wrapper that needs a simple boolean like `shouldAllowIdentificationCreation` triggers all of this. + +In the portal path this is fine — the page component calls it once. In the composed path with N independent section wrappers, it's called N times per render. + +**Mitigation:** Extract the guard-related values into a lighter hook (e.g., `useUserProfileGuards()`) that doesn't pull billing data or page routes. + +### 6. Billing sections only work inside billing Page + +Billing sections call `navigate('plans')`, `navigate('statement/${id}')`, etc. These are handled by `useBillingRouter` which only exists when `` is the parent. + +If someone puts `
` under ``: + +- `PageContext` says "account", so the section registry lookup would fail (subscriptions isn't an account section) → renders null. This is the designed behavior (Rule 4). + +But if someone puts billing sections under `` without children (passthrough mode), the existing BillingPage with tabs renders — section composition is ignored. Need to document this clearly. + +### 7. StrictMode compatibility + +We already found that `useSafeState` (used by `useLoadingStatus`) breaks in React 18 StrictMode (the `isMountedRef` is never reset after remount). We fixed it, but other hooks may have similar patterns. Each section that uses form submission, loading states, or action menus could be affected. The portal path doesn't use StrictMode, so these bugs only surface in the composed path. + +**The composed playground uses ``.** Any host app might too. We should audit `useSafeState` consumers for similar issues. + +--- + +## Verification + +1. **Default rendering**: `` renders identically to current `` +2. **Section omission**: `
` renders only the profile section +3. **Custom content**: Non-Section children render inline between sections +4. **Environment guards**: Sections hidden by dashboard config render nothing even when explicitly declared +5. **Atomic pages**: Billing tabs, sub-navigation (plans, statements, payments) all work +6. **Error handling**: Section errors surface via `useCardState()` (available when page provides CardStateProvider) +7. **Existing tests**: `pnpm turbo test --filter=@clerk/ui` — all portal-path tests pass unchanged +8. **Playground**: Update `playground/composed/` to exercise all patterns above From 7b8b327231e40669c379628b7e0c6b5fded0b8b0 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 22 May 2026 15:47:25 -0400 Subject: [PATCH 05/24] init --- .../OrganizationGeneralPage.tsx | 8 +- .../composed/OrganizationProfile/General.tsx | 38 +- .../composed/OrganizationProfile/index.tsx | 10 + .../OrganizationProfile/sectionWrappers.tsx | 45 ++ packages/ui/src/composed/PageContext.tsx | 5 + .../ui/src/composed/UserProfile/Account.tsx | 50 ++- .../ui/src/composed/UserProfile/Security.tsx | 50 ++- .../ui/src/composed/UserProfile/index.tsx | 26 ++ .../composed/UserProfile/sectionWrappers.tsx | 164 +++++++ .../OrganizationProfileSections.test.tsx | 192 ++++++++ .../__tests__/UserProfileSections.test.tsx | 419 ++++++++++++++++++ playground/composed/src/App.tsx | 95 +++- 12 files changed, 1087 insertions(+), 15 deletions(-) create mode 100644 packages/ui/src/composed/OrganizationProfile/sectionWrappers.tsx create mode 100644 packages/ui/src/composed/PageContext.tsx create mode 100644 packages/ui/src/composed/UserProfile/sectionWrappers.tsx create mode 100644 packages/ui/src/composed/__tests__/OrganizationProfileSections.test.tsx create mode 100644 packages/ui/src/composed/__tests__/UserProfileSections.test.tsx diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationGeneralPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationGeneralPage.tsx index fc80e641a6a..cad67e2b1c4 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationGeneralPage.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationGeneralPage.tsx @@ -85,7 +85,7 @@ export const OrganizationGeneralPage = () => { ); }; -const OrganizationProfileSection = () => { +export const OrganizationProfileSection = () => { const { organization } = useOrganization(); if (!organization) { @@ -134,7 +134,7 @@ const OrganizationProfileSection = () => { ); }; -const OrganizationDomainsSection = () => { +export const OrganizationDomainsSection = () => { const { organizationSettings } = useEnvironment(); const { organization } = useOrganization(); @@ -183,7 +183,7 @@ const OrganizationDomainsSection = () => { ); }; -const OrganizationLeaveSection = () => { +export const OrganizationLeaveSection = () => { const { organization } = useOrganization(); if (!organization) { @@ -229,7 +229,7 @@ const OrganizationLeaveSection = () => { ); }; -const OrganizationDeleteSection = () => { +export const OrganizationDeleteSection = () => { const { organization } = useOrganization(); const canDeleteOrganization = useProtect({ permission: 'org:sys_profile:delete' }); diff --git a/packages/ui/src/composed/OrganizationProfile/General.tsx b/packages/ui/src/composed/OrganizationProfile/General.tsx index 2242af2667b..7c1e17a38f4 100644 --- a/packages/ui/src/composed/OrganizationProfile/General.tsx +++ b/packages/ui/src/composed/OrganizationProfile/General.tsx @@ -1,3 +1,39 @@ +import type { PropsWithChildren } from 'react'; + +import { Header } from '@/ui/elements/Header'; +import { ProfileCard } from '@/ui/elements/ProfileCard'; + +import { Col, descriptors, localizationKeys } from '../../customizables'; import { OrganizationGeneralPage } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { PageContext } from '../PageContext'; + +export function General({ children }: PropsWithChildren) { + if (!children) { + return ; + } -export const General = () => ; + return ( + + + ({ gap: t.space.$8, isolation: 'isolate' })} + > + + + ({ marginBottom: t.space.$4 })} + textVariant='h2' + /> + + {children} + + + + + ); +} diff --git a/packages/ui/src/composed/OrganizationProfile/index.tsx b/packages/ui/src/composed/OrganizationProfile/index.tsx index 364b926a3b5..35df352007a 100644 --- a/packages/ui/src/composed/OrganizationProfile/index.tsx +++ b/packages/ui/src/composed/OrganizationProfile/index.tsx @@ -3,6 +3,12 @@ import { Billing } from './Billing'; import { General } from './General'; import { Members } from './Members'; import { OrganizationProfileProvider } from './OrganizationProfileProvider'; +import { + GeneralDeleteOrganization, + GeneralLeaveOrganization, + GeneralOrganizationProfile, + GeneralVerifiedDomains, +} from './sectionWrappers'; export const OrganizationProfile = { Provider: OrganizationProfileProvider, @@ -10,4 +16,8 @@ export const OrganizationProfile = { Members, Billing, APIKeys, + GeneralOrganizationProfile, + GeneralVerifiedDomains, + GeneralLeaveOrganization, + GeneralDeleteOrganization, }; diff --git a/packages/ui/src/composed/OrganizationProfile/sectionWrappers.tsx b/packages/ui/src/composed/OrganizationProfile/sectionWrappers.tsx new file mode 100644 index 00000000000..8ca80b4eedc --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/sectionWrappers.tsx @@ -0,0 +1,45 @@ +import { useContext } from 'react'; + +import { Protect } from '../../common'; +import { + OrganizationDeleteSection, + OrganizationDomainsSection, + OrganizationLeaveSection, + OrganizationProfileSection, +} from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { PageContext } from '../PageContext'; + +function useRequirePage(componentName: string): boolean { + const page = useContext(PageContext); + if (!page) { + if (typeof __DEV__ !== 'undefined' && __DEV__) { + console.warn(`${componentName} must be used inside a page component (e.g. OrganizationProfile.General)`); + } + return false; + } + return true; +} + +export function GeneralOrganizationProfile() { + if (!useRequirePage('GeneralOrganizationProfile')) return null; + return ; +} + +export function GeneralVerifiedDomains() { + if (!useRequirePage('GeneralVerifiedDomains')) return null; + return ( + + + + ); +} + +export function GeneralLeaveOrganization() { + if (!useRequirePage('GeneralLeaveOrganization')) return null; + return ; +} + +export function GeneralDeleteOrganization() { + if (!useRequirePage('GeneralDeleteOrganization')) return null; + return ; +} 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/UserProfile/Account.tsx b/packages/ui/src/composed/UserProfile/Account.tsx index b0d9720d29f..90e4fbd2150 100644 --- a/packages/ui/src/composed/UserProfile/Account.tsx +++ b/packages/ui/src/composed/UserProfile/Account.tsx @@ -1,3 +1,51 @@ +import type { PropsWithChildren } from 'react'; + +import { Card } from '@/ui/elements/Card'; +import { CardStateProvider, useCardState } from '@/ui/elements/contexts'; +import { Header } from '@/ui/elements/Header'; +import { ProfileCard } from '@/ui/elements/ProfileCard'; + +import { Col, descriptors, localizationKeys } from '../../customizables'; import { AccountPage } from '../../components/UserProfile/AccountPage'; +import { PageContext } from '../PageContext'; + +function AccountChrome({ children }: PropsWithChildren) { + const card = useCardState(); + return ( + + ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} + > + + + ({ marginBottom: t.space.$4 })} + textVariant='h2' + /> + + {card.error} + {children} + + + + ); +} + +export function Account({ children }: PropsWithChildren) { + if (!children) { + return ; + } -export const Account = () => ; + return ( + + + {children} + + + ); +} diff --git a/packages/ui/src/composed/UserProfile/Security.tsx b/packages/ui/src/composed/UserProfile/Security.tsx index 53c5966110c..64ecf65e08a 100644 --- a/packages/ui/src/composed/UserProfile/Security.tsx +++ b/packages/ui/src/composed/UserProfile/Security.tsx @@ -1,3 +1,51 @@ +import type { PropsWithChildren } from 'react'; + +import { Card } from '@/ui/elements/Card'; +import { CardStateProvider, useCardState } from '@/ui/elements/contexts'; +import { Header } from '@/ui/elements/Header'; +import { ProfileCard } from '@/ui/elements/ProfileCard'; + +import { Col, descriptors, localizationKeys } from '../../customizables'; import { SecurityPage } from '../../components/UserProfile/SecurityPage'; +import { PageContext } from '../PageContext'; + +function SecurityChrome({ children }: PropsWithChildren) { + const card = useCardState(); + return ( + + ({ gap: t.space.$8, isolation: 'isolate' })} + > + + + ({ marginBottom: t.space.$4 })} + textVariant='h2' + /> + + {card.error} + {children} + + + + ); +} + +export function Security({ children }: PropsWithChildren) { + if (!children) { + return ; + } -export const Security = () => ; + return ( + + + {children} + + + ); +} diff --git a/packages/ui/src/composed/UserProfile/index.tsx b/packages/ui/src/composed/UserProfile/index.tsx index a8f4d43bc11..3d1282161ee 100644 --- a/packages/ui/src/composed/UserProfile/index.tsx +++ b/packages/ui/src/composed/UserProfile/index.tsx @@ -2,6 +2,20 @@ import { Account } from './Account'; import { APIKeys } from './APIKeys'; import { Billing } from './Billing'; import { Security } from './Security'; +import { + AccountConnectedAccounts, + AccountEmails, + AccountEnterpriseAccounts, + AccountPhone, + AccountProfile, + AccountUsername, + AccountWeb3, + SecurityActiveDevices, + SecurityDelete, + SecurityMfa, + SecurityPasskeys, + SecurityPassword, +} from './sectionWrappers'; import { UserProfileProvider } from './UserProfileProvider'; export const UserProfile = { @@ -10,4 +24,16 @@ export const UserProfile = { Security, Billing, APIKeys, + AccountProfile, + AccountUsername, + AccountEmails, + AccountPhone, + AccountConnectedAccounts, + AccountEnterpriseAccounts, + AccountWeb3, + SecurityPassword, + SecurityPasskeys, + SecurityMfa, + SecurityActiveDevices, + SecurityDelete, }; diff --git a/packages/ui/src/composed/UserProfile/sectionWrappers.tsx b/packages/ui/src/composed/UserProfile/sectionWrappers.tsx new file mode 100644 index 00000000000..7a09f82a67d --- /dev/null +++ b/packages/ui/src/composed/UserProfile/sectionWrappers.tsx @@ -0,0 +1,164 @@ +import { useUser } from '@clerk/shared/react'; +import { useContext } from 'react'; + +import { getSecondFactors } from '@/ui/utils/mfa'; + +import { useEnvironment, useUserProfileContext } from '../../contexts'; +import { ActiveDevicesSection } from '../../components/UserProfile/ActiveDevicesSection'; +import { ConnectedAccountsSection } from '../../components/UserProfile/ConnectedAccountsSection'; +import { DeleteSection } from '../../components/UserProfile/DeleteSection'; +import { EmailsSection } from '../../components/UserProfile/EmailsSection'; +import { EnterpriseAccountsSection } from '../../components/UserProfile/EnterpriseAccountsSection'; +import { MfaSection } from '../../components/UserProfile/MfaSection'; +import { PasskeySection } from '../../components/UserProfile/PasskeySection'; +import { PasswordSection } from '../../components/UserProfile/PasswordSection'; +import { PhoneSection } from '../../components/UserProfile/PhoneSection'; +import { UsernameSection } from '../../components/UserProfile/UsernameSection'; +import { UserProfileSection } from '../../components/UserProfile/UserProfileSection'; +import { Web3Section } from '../../components/UserProfile/Web3Section'; +import { PageContext } from '../PageContext'; + +function useRequirePage(componentName: string): boolean { + const page = useContext(PageContext); + if (!page) { + if (typeof __DEV__ !== 'undefined' && __DEV__) { + console.warn(`${componentName} must be used inside a page component (e.g. UserProfile.Account)`); + } + return false; + } + return true; +} + +// --- Account sections --- + +export function AccountProfile() { + if (!useRequirePage('AccountProfile')) return null; + return ; +} + +export function AccountUsername() { + if (!useRequirePage('AccountUsername')) return null; + + const { attributes } = useEnvironment().userSettings; + const { immutableAttributes } = useUserProfileContext(); + + if (!attributes.username?.enabled) return null; + + const isImmutable = immutableAttributes.has('username'); + return ; +} + +export function AccountEmails() { + if (!useRequirePage('AccountEmails')) return null; + + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext(); + + if (!attributes.email_address?.enabled) return null; + + const isImmutable = immutableAttributes.has('email_address'); + return ( + + ); +} + +export function AccountPhone() { + if (!useRequirePage('AccountPhone')) return null; + + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext(); + + if (!attributes.phone_number?.enabled) return null; + + const isImmutable = immutableAttributes.has('phone_number'); + return ( + + ); +} + +export function AccountConnectedAccounts() { + if (!useRequirePage('AccountConnectedAccounts')) return null; + + const { social } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation } = useUserProfileContext(); + + if (!social || Object.values(social).filter(p => p.enabled).length === 0) return null; + + return ; +} + +export function AccountEnterpriseAccounts() { + if (!useRequirePage('AccountEnterpriseAccounts')) return null; + + const { enterpriseSSO } = useEnvironment().userSettings; + const { user } = useUser(); + + if (!user || !enterpriseSSO.enabled) return null; + + return ; +} + +export function AccountWeb3() { + if (!useRequirePage('AccountWeb3')) return null; + + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation } = useUserProfileContext(); + + if (!attributes.web3_wallet?.enabled) return null; + + return ; +} + +// --- Security sections --- + +export function SecurityPassword() { + if (!useRequirePage('SecurityPassword')) return null; + + const { instanceIsPasswordBased } = useEnvironment().userSettings; + + if (!instanceIsPasswordBased) return null; + + return ; +} + +export function SecurityPasskeys() { + if (!useRequirePage('SecurityPasskeys')) return null; + + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation } = useUserProfileContext(); + + if (!attributes.passkey?.enabled || !shouldAllowIdentificationCreation) return null; + + return ; +} + +export function SecurityMfa() { + if (!useRequirePage('SecurityMfa')) return null; + + const { attributes } = useEnvironment().userSettings; + + if (getSecondFactors(attributes).length === 0) return null; + + return ; +} + +export function SecurityActiveDevices() { + if (!useRequirePage('SecurityActiveDevices')) return null; + return ; +} + +export function SecurityDelete() { + if (!useRequirePage('SecurityDelete')) return null; + + const { user } = useUser(); + + if (!user?.deleteSelfEnabled) return null; + + return ; +} 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..18e3495954a --- /dev/null +++ b/packages/ui/src/composed/__tests__/OrganizationProfileSections.test.tsx @@ -0,0 +1,192 @@ +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 { General } from '../OrganizationProfile/General'; +import { + GeneralDeleteOrganization, + GeneralLeaveOrganization, + GeneralOrganizationProfile, + GeneralVerifiedDomains, +} from '../OrganizationProfile/sectionWrappers'; + +const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + +describe('OrganizationProfile composed sections', () => { + beforeEach(() => { + clearFetchCache(); + }); + + describe('General — passthrough mode', () => { + it('renders org 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'); + }); + + it('renders domains section when enabled', 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('hides domains section when disabled', 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 }); + expect(queryByText(/verified domains/i)).not.toBeInTheDocument(); + }); + }); + + 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('GeneralVerifiedDomains 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('GeneralDeleteOrganization 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('GeneralLeaveOrganization 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('section without page renders null', 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 { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); +}); 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..f1c8d4ba219 --- /dev/null +++ b/packages/ui/src/composed/__tests__/UserProfileSections.test.tsx @@ -0,0 +1,419 @@ +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 { Account } from '../UserProfile/Account'; +import { Security } from '../UserProfile/Security'; +import { + AccountConnectedAccounts, + AccountEmails, + AccountEnterpriseAccounts, + AccountPhone, + AccountProfile, + AccountUsername, + AccountWeb3, + SecurityActiveDevices, + SecurityDelete, + SecurityMfa, + SecurityPasskeys, + SecurityPassword, +} from '../UserProfile/sectionWrappers'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('UserProfile composed sections', () => { + beforeEach(() => { + clearFetchCache(); + }); + + describe('Account — passthrough mode', () => { + it('renders all enabled sections', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withPhoneNumber(); + f.withUsername(); + f.withSocialProvider({ provider: 'google' }); + f.withUser({ + first_name: 'Test', + last_name: 'User', + email_addresses: ['test@clerk.com'], + phone_numbers: ['+11111111111'], + username: 'testuser', + external_accounts: [{ provider: 'google', email_address: 'test@clerk.com' }], + }); + }); + + render(, { wrapper }); + screen.getByText('Test User'); + screen.getByText('testuser'); + expect(screen.getAllByText(/email address/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/phone number/i).length).toBeGreaterThan(0); + screen.getByText(/connected accounts/i); + }); + + it('hides disabled sections', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + + const { queryByText } = render(, { wrapper }); + expect(queryByText(/phone number/i)).not.toBeInTheDocument(); + expect(queryByText(/connected accounts/i)).not.toBeInTheDocument(); + }); + + 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('AccountProfile 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('AccountUsername 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('AccountUsername 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('AccountEmails 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('AccountPhone 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('AccountConnectedAccounts 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('AccountEnterpriseAccounts 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('AccountWeb3 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); + }); + + it('AccountWeb3 connect wallet calls createWeb3Wallet with a valid identifier', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withWeb3Wallet(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { userEvent } = render( + + + , + { wrapper }, + ); + + // Click "Connect wallet" to open the provider menu + const connectButton = screen.getByRole('button', { name: /connect wallet/i }); + await userEvent.click(connectButton); + + // Click the MetaMask option + const metamaskItem = await screen.findByRole('menuitem', { name: /metamask/i }); + await userEvent.click(metamaskItem); + + // The connect flow should call createWeb3Wallet with a non-empty identifier + // This fails because stubModuleManager returns undefined for @metamask imports, + // causing getWeb3Identifier to return an empty string + await waitFor(() => { + expect(fixtures.clerk.user?.createWeb3Wallet).toHaveBeenCalled(); + }); + const callArgs = (fixtures.clerk.user?.createWeb3Wallet as any).mock.calls[0]; + expect(callArgs[0].web3Wallet).not.toBe(''); + }); + }); + + describe('Account — section outside page', () => { + it('section without page renders null', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('Security — passthrough mode', () => { + it('renders all enabled 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([])); + + render(, { wrapper }); + await waitFor(() => screen.getByText(/^password/i)); + screen.getByText(/^passkeys/i); + screen.getByText(/active devices/i); + expect(screen.getAllByText(/delete account/i).length).toBeGreaterThan(0); + }); + + it('hides disabled sections', 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)); + expect(screen.queryByText(/^password/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/^passkeys/i)).not.toBeInTheDocument(); + }); + }); + + 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('SecurityActiveDevices 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('SecurityDelete 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/playground/composed/src/App.tsx b/playground/composed/src/App.tsx index 3598749ef4a..1d1ebd270eb 100644 --- a/playground/composed/src/App.tsx +++ b/playground/composed/src/App.tsx @@ -5,11 +5,13 @@ import { useState } from 'react'; type ProfileType = 'user' | 'organization'; type UserTab = 'account' | 'security' | 'billing' | 'api-keys'; type OrgTab = 'general' | 'members' | 'billing' | 'api-keys'; +type ComposedMode = 'passthrough' | 'composed'; export function App() { const [profileType, setProfileType] = useState('user'); const [userTab, setUserTab] = useState('account'); const [orgTab, setOrgTab] = useState('general'); + const [composedMode, setComposedMode] = useState('passthrough'); return (
@@ -53,6 +55,37 @@ export function App() {
+
+ + +
+ {profileType === 'user' && ( - {userTab === 'account' && } - {userTab === 'security' && } - {userTab === 'billing' && } - {userTab === 'api-keys' && } + {composedMode === 'passthrough' ? ( + <> + {userTab === 'account' && } + {userTab === 'security' && } + {userTab === 'billing' && } + {userTab === 'api-keys' && } + + ) : ( + <> + {userTab === 'account' && ( + + +
hello world
+ + + + +
+ )} + {userTab === 'security' && ( + + + + + + + + )} + {userTab === 'billing' && } + {userTab === 'api-keys' && } + + )}
)} @@ -74,10 +135,28 @@ export function App() { active={orgTab} onChange={setOrgTab} /> - {orgTab === 'general' && } - {orgTab === 'members' && } - {orgTab === 'billing' && } - {orgTab === 'api-keys' && } + {composedMode === 'passthrough' ? ( + <> + {orgTab === 'general' && } + {orgTab === 'members' && } + {orgTab === 'billing' && } + {orgTab === 'api-keys' && } + + ) : ( + <> + {orgTab === 'general' && ( + + + + + + + )} + {orgTab === 'members' && } + {orgTab === 'billing' && } + {orgTab === 'api-keys' && } + + )} )} From 20bbc37a6886867179aa48742c98e2296d9668df Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 22 May 2026 18:10:15 -0400 Subject: [PATCH 06/24] wip --- packages/ui/src/ClerkUI.ts | 2 + .../OrganizationProfileProvider.tsx | 15 +++-- .../UserProfile/UserProfileProvider.tsx | 15 +++-- .../__tests__/stub-limitations.test.ts | 57 ++++++++++++++++++ .../ui/src/composed/moduleManagerStore.ts | 11 ++++ packages/ui/src/composed/stubRouter.ts | 59 ++++++++----------- packages/ui/src/primitives/Spinner.tsx | 1 + playground/composed/src/main.tsx | 6 +- 8 files changed, 120 insertions(+), 46 deletions(-) create mode 100644 packages/ui/src/composed/__tests__/stub-limitations.test.ts create mode 100644 packages/ui/src/composed/moduleManagerStore.ts diff --git a/packages/ui/src/ClerkUI.ts b/packages/ui/src/ClerkUI.ts index c6bee5da754..8864e31f58d 100644 --- a/packages/ui/src/ClerkUI.ts +++ b/packages/ui/src/ClerkUI.ts @@ -8,6 +8,7 @@ import type { ClerkUIInstance, ComponentControls as SharedComponentControls } fr import { isVersionAtLeast, parseVersion } from '@clerk/shared/versionCheck'; import { type MountComponentRenderer, mountComponentRenderer } from './Components'; +import { setModuleManager } from './composed/moduleManagerStore'; import { MIN_CLERK_JS_VERSION } from './constants'; /** @@ -78,6 +79,7 @@ export class ClerkUI implements ClerkUIInstance { } } + setModuleManager(moduleManager); this.#componentRenderer = mountComponentRenderer(getClerk, getEnvironment, options, moduleManager); } diff --git a/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx b/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx index 4c7722737bc..cbe291f1ba0 100644 --- a/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx +++ b/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx @@ -1,7 +1,7 @@ import type { ModuleManager } from '@clerk/shared/moduleManager'; import { useClerk, useOrganization, useUser } from '@clerk/shared/react'; import type { EnvironmentResource, OAuthProvider, OAuthScope } from '@clerk/shared/types'; -import React from 'react'; +import React, { useMemo } from 'react'; import { AppearanceProvider } from '@/ui/customizables/AppearanceContext'; import { FlowMetadataProvider } from '@/ui/elements/contexts'; @@ -16,10 +16,11 @@ import { OptionsProvider } from '../../contexts/OptionsContext'; import { SubscriberTypeContext } from '../../contexts/components/SubscriberType'; import { OrganizationProfileContext } from '../../contexts/components/OrganizationProfile'; import { ProfileCardPagePaddingProvider } from '../../elements/ProfileCard'; -import { stubRouter } from '../stubRouter'; +import { getModuleManager } from '../moduleManagerStore'; +import { createComposedRouter } from '../stubRouter'; -const stubModuleManager: ModuleManager = { - import: () => Promise.resolve(undefined), +const fallbackModuleManager: ModuleManager = { + import: () => Promise.resolve(undefined) as any, }; type OrganizationProfileProviderProps = React.PropsWithChildren<{ @@ -34,6 +35,8 @@ export const OrganizationProfileProvider = (props: OrganizationProfileProviderPr const { organization } = useOrganization(); const environment = (clerk as any).__internal_environment as EnvironmentResource | null | undefined; + const moduleManager: ModuleManager = getModuleManager() ?? fallbackModuleManager; + const router = useMemo(() => createComposedRouter(clerk.navigate), [clerk]); if (!isLoaded || !user || !organization || !environment) { return null; @@ -55,10 +58,10 @@ export const OrganizationProfileProvider = (props: OrganizationProfileProviderPr > - + - + {children} diff --git a/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx index a074a49ca04..3047fd07c87 100644 --- a/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx +++ b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx @@ -1,7 +1,7 @@ import type { ModuleManager } from '@clerk/shared/moduleManager'; import { useClerk, useUser } from '@clerk/shared/react'; import type { EnvironmentResource, OAuthProvider, OAuthScope } from '@clerk/shared/types'; -import React from 'react'; +import React, { useMemo } from 'react'; import { AppearanceProvider } from '@/ui/customizables/AppearanceContext'; import { FlowMetadataProvider } from '@/ui/elements/contexts'; @@ -15,10 +15,11 @@ import { ModuleManagerProvider } from '../../contexts/ModuleManagerContext'; import { OptionsProvider } from '../../contexts/OptionsContext'; import { UserProfileContext } from '../../contexts/components/UserProfile'; import { ProfileCardPagePaddingProvider } from '../../elements/ProfileCard'; -import { stubRouter } from '../stubRouter'; +import { getModuleManager } from '../moduleManagerStore'; +import { createComposedRouter } from '../stubRouter'; -const stubModuleManager: ModuleManager = { - import: () => Promise.resolve(undefined), +const fallbackModuleManager: ModuleManager = { + import: () => Promise.resolve(undefined) as any, }; type UserProfileProviderProps = React.PropsWithChildren<{ @@ -32,6 +33,8 @@ export const UserProfileProvider = (props: UserProfileProviderProps) => { const { isLoaded, user } = useUser(); const environment = (clerk as any).__internal_environment as EnvironmentResource | null | undefined; + const moduleManager: ModuleManager = getModuleManager() ?? fallbackModuleManager; + const router = useMemo(() => createComposedRouter(clerk.navigate), [clerk]); if (!isLoaded || !user || !environment) { return null; @@ -54,10 +57,10 @@ export const UserProfileProvider = (props: UserProfileProviderProps) => { > - + - + {children} 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..d685f605b29 --- /dev/null +++ b/packages/ui/src/composed/__tests__/stub-limitations.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createComposedRouter, stubRouter } from '../stubRouter'; + +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('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(); + }); +}); diff --git a/packages/ui/src/composed/moduleManagerStore.ts b/packages/ui/src/composed/moduleManagerStore.ts new file mode 100644 index 00000000000..94a5fa1421e --- /dev/null +++ b/packages/ui/src/composed/moduleManagerStore.ts @@ -0,0 +1,11 @@ +import type { ModuleManager } from '@clerk/shared/moduleManager'; + +let storedModuleManager: ModuleManager | undefined; + +export function setModuleManager(mm: ModuleManager): void { + storedModuleManager = mm; +} + +export function getModuleManager(): ModuleManager | undefined { + return storedModuleManager; +} diff --git a/packages/ui/src/composed/stubRouter.ts b/packages/ui/src/composed/stubRouter.ts index b334c193a78..52f0245148d 100644 --- a/packages/ui/src/composed/stubRouter.ts +++ b/packages/ui/src/composed/stubRouter.ts @@ -1,37 +1,30 @@ import type { RouteContextValue } from '../router/RouteContext'; -const noop = () => {}; - -function isExternalUrl(to: string): boolean { - try { - return new URL(to).origin !== window.location.origin; - } catch { - return false; - } +export function createComposedRouter(clerkNavigate: (to: string) => Promise | void): RouteContextValue { + return { + basePath: '', + startPath: '', + flowStartPath: '', + fullPath: '', + indexPath: '', + currentPath: '', + matches: () => false, + navigate: async (to: string) => { + await clerkNavigate(to); + }, + baseNavigate: async (toURL: URL) => { + await clerkNavigate(toURL.href); + }, + resolve: (to: string) => new URL(to, window.location.href), + refresh: () => {}, + params: {}, + queryString: '', + queryParams: {}, + getMatchData: () => false, + }; } -export const stubRouter: RouteContextValue = { - basePath: '', - startPath: '', - flowStartPath: '', - fullPath: '', - indexPath: '', - currentPath: '', - matches: () => false, - baseNavigate: async (toURL: URL) => { - if (toURL.origin !== window.location.origin) { - window.location.href = toURL.href; - } - }, - navigate: async (to: string) => { - if (isExternalUrl(to)) { - window.location.href = to; - } - }, - resolve: (to: string) => new URL(to, window.location.origin), - refresh: noop, - params: {}, - queryString: '', - queryParams: {}, - getMatchData: () => false, -}; +export const stubRouter: RouteContextValue = createComposedRouter(to => { + window.location.assign(to); + return Promise.resolve(); +}); diff --git a/packages/ui/src/primitives/Spinner.tsx b/packages/ui/src/primitives/Spinner.tsx index a528596e348..5f0f5eed7b4 100644 --- a/packages/ui/src/primitives/Spinner.tsx +++ b/packages/ui/src/primitives/Spinner.tsx @@ -6,6 +6,7 @@ const { size, thickness, speed } = createCssVariables('speed', 'size', 'thicknes const { applyVariants, filterProps } = createVariants(theme => { return { base: { + boxSizing: 'border-box', display: 'inline-block', borderRadius: '99999px', borderTop: `${thickness} solid currentColor`, diff --git a/playground/composed/src/main.tsx b/playground/composed/src/main.tsx index 7a7d6ea12d1..d9633163e7d 100644 --- a/playground/composed/src/main.tsx +++ b/playground/composed/src/main.tsx @@ -1,4 +1,5 @@ import { ClerkProvider } from '@clerk/react'; +import { ui } from '@clerk/ui'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; @@ -12,7 +13,10 @@ if (!publishableKey) { createRoot(document.getElementById('root')!).render( - + , From 6d6b9fe9acea1c00c7de54fcf3d08e7fbdb8745d Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Sat, 23 May 2026 07:35:28 -0400 Subject: [PATCH 07/24] Create composed-provider-wiring.test.tsx --- .../composed-provider-wiring.test.tsx | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 packages/ui/src/composed/__tests__/composed-provider-wiring.test.tsx 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..5b72989a2e3 --- /dev/null +++ b/packages/ui/src/composed/__tests__/composed-provider-wiring.test.tsx @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useModuleManager } from '@/ui/contexts'; +import { useRouter } from '@/ui/router'; +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { clearFetchCache } from '../../hooks'; +import { setModuleManager } from '../moduleManagerStore'; +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 }); +} + +function ModuleManagerProbe() { + const mm = useModuleManager(); + return ( +
+ ); +} + +function RouterProbe() { + const router = useRouter(); + return ( +
+ ); +} + +describe('UserProfileProvider wiring', () => { + const { createFixtures } = bindCreateFixtures('UserProfile'); + + beforeEach(() => { + clearFetchCache(); + }); + + afterEach(() => { + setModuleManager(undefined as any); + }); + + it('provides the stored moduleManager to children', async () => { + const mockImport = vi.fn(() => Promise.resolve(undefined)); + setModuleManager({ import: mockImport }); + + 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 fallback moduleManager when store is empty', 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('provides a router that delegates to clerk.navigate', async () => { + setModuleManager({ import: vi.fn(() => Promise.resolve(undefined)) }); + + 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'); + }); + + it('returns null when user is not loaded', async () => { + setModuleManager({ import: vi.fn(() => Promise.resolve(undefined)) }); + + const { wrapper, fixtures } = await createFixtures(); + patchEnvironment(fixtures.clerk, fixtures.environment); + + const { container } = render( + +
+ , + { wrapper }, + ); + + expect(screen.queryByTestId('should-not-render')).not.toBeInTheDocument(); + }); + + it('returns null when environment is missing', async () => { + setModuleManager({ import: vi.fn(() => Promise.resolve(undefined)) }); + + 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(); + }); +}); + +describe('OrganizationProfileProvider wiring', () => { + const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + + beforeEach(() => { + clearFetchCache(); + }); + + afterEach(() => { + setModuleManager(undefined as any); + }); + + it('provides the stored moduleManager to children', async () => { + const mockImport = vi.fn(() => Promise.resolve(undefined)); + setModuleManager({ import: mockImport }); + + 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('returns null when organization is not loaded', async () => { + setModuleManager({ import: vi.fn(() => Promise.resolve(undefined)) }); + + 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(); + }); +}); From 87745cbd7402b29c7b6f7b3e6cd551a7e81c4a22 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 26 May 2026 11:01:46 -0400 Subject: [PATCH 08/24] docs --- packages/ui/COMPOSED_WORKING_DOC.md | 104 ++++ .../ui/src/composed/COMPONENT_INJECTION.md | 230 ++++++++ packages/ui/src/mosaic/MOSAIC_COMPARISON.md | 340 +++++++++++ packages/ui/src/mosaic/MOSAIC_DX_GUIDE.md | 422 ++++++++++++++ packages/ui/src/mosaic/MOSAIC_PLAN.md | 436 ++++++++++++++ .../ui/src/mosaic/MOSAIC_PLAN_PIGMENT_CSS.md | 520 +++++++++++++++++ .../ui/src/mosaic/MOSAIC_PLAN_TAILWIND.md | 546 ++++++++++++++++++ 7 files changed, 2598 insertions(+) create mode 100644 packages/ui/COMPOSED_WORKING_DOC.md create mode 100644 packages/ui/src/composed/COMPONENT_INJECTION.md create mode 100644 packages/ui/src/mosaic/MOSAIC_COMPARISON.md create mode 100644 packages/ui/src/mosaic/MOSAIC_DX_GUIDE.md create mode 100644 packages/ui/src/mosaic/MOSAIC_PLAN.md create mode 100644 packages/ui/src/mosaic/MOSAIC_PLAN_PIGMENT_CSS.md create mode 100644 packages/ui/src/mosaic/MOSAIC_PLAN_TAILWIND.md diff --git a/packages/ui/COMPOSED_WORKING_DOC.md b/packages/ui/COMPOSED_WORKING_DOC.md new file mode 100644 index 00000000000..35856c36854 --- /dev/null +++ b/packages/ui/COMPOSED_WORKING_DOC.md @@ -0,0 +1,104 @@ +# Composed Profile API — Working Doc + +## Current Status + +The composed profile providers (`UserProfile.Provider`, `OrganizationProfile.Provider`) now support full section-level composition and functional parity with the AIO `` / `` components. Features like Coinbase wallet connect, password strength scoring, and navigation (leave org, delete org, cross-section links) all work through the composed API. + +**Requirement**: `ui={ui}` must be passed to `` when using composed components. + +```tsx +import { ui } from '@clerk/ui'; +import { ClerkProvider } from '@clerk/react'; + + + ... +; +``` + +## What Changed + +### 1. ModuleManager — real dynamic imports for composed providers + +**Problem**: Composed providers used a stub `ModuleManager` that returned `undefined` for all dynamic imports. This broke: + +- Coinbase wallet connect (`@coinbase/wallet-sdk`) +- Base wallet connect (`@base-org/account`) +- Password strength scoring (`@zxcvbn-ts/core`) + +**Root cause**: The real `ModuleManager` is created inside `clerk.load()` (in clerk-js) and passed to the `ClerkUI` constructor. But the composed providers render in the **user's React tree**, not in ClerkUI's tree — they had no way to access it. + +**Rejected approach**: Expose `ModuleManager` on the Clerk instance via a `__internal_moduleManager` getter on `clerk.ts`. This would work but requires a clerk-js release before it takes effect, since clerk-js loads from CDN at runtime. The getter doesn't exist on the published CDN build. + +**Implemented approach**: Module-level singleton store in `@clerk/ui`. When `ClerkUI` is constructed (which happens when `ui={ui}` is passed to `ClerkProvider`), it stores the `moduleManager` in a module-level variable. The composed providers read from that store. Since both `ClerkUI` and the composed providers are in the same `@clerk/ui` bundle, they share module scope. + +**Files**: + +- `packages/ui/src/composed/moduleManagerStore.ts` — `setModuleManager()` / `getModuleManager()` +- `packages/ui/src/ClerkUI.ts` — calls `setModuleManager(moduleManager)` in constructor +- `packages/ui/src/composed/UserProfile/UserProfileProvider.tsx` — reads from store +- `packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx` — reads from store + +**Caveat**: Only works when `ui={ui}` is passed. Without it, ClerkUI loads from CDN as a separate bundle (different module scope), so the store is never populated. A `fallbackModuleManager` that returns `undefined` is used in that case — features degrade silently. + +### 2. Router — real navigation for composed providers + +**Problem**: The stub router only handled external URLs via `window.location.href`. Same-origin paths (`'/'`, `'../'`, `'./org-general'`) were silently dropped. This broke: + +- Leave organization (navigates to `/`) +- Delete organization +- Cross-section links (e.g. "Manage domains") +- Error card back buttons + +**Implemented approach**: `createComposedRouter(clerkNavigate)` factory that delegates all navigation to `clerk.navigate` — the public method on `LoadedClerk` that handles external URLs, same-origin with framework router, and fallback to `window.location`. + +**Files**: + +- `packages/ui/src/composed/stubRouter.ts` — `createComposedRouter()` factory, `stubRouter` kept as fallback +- Both provider files — `useMemo(() => createComposedRouter(clerk.navigate), [clerk])` + +## Issues Encountered + +### clerk-js loads from CDN, not local source + +The playground (and all apps using `@clerk/react`) load clerk-js at runtime from a CDN script (`loadClerkJSScript`). Changes to `packages/clerk-js/src/core/clerk.ts` have no effect until published. This is why the `__internal_moduleManager` getter approach was abandoned — it required a clerk-js release to test. + +### `@clerk/ui` dist must be rebuilt for playground testing + +Vite resolves `@clerk/ui` from its built `dist/` directory (via package.json exports), not from source. After making changes to `@clerk/ui`, you must run `pnpm turbo build --filter=@clerk/ui --force` before testing in the playground. + +### Spinner layout shift in composed mode + +When clicking "Connect wallet" in the composed path, the loading spinner causes a slight content shift in the action menu that doesn't happen in the AIO component. Likely caused by the composed components rendering inline in the host page's DOM (inheriting the page's CSS), while the AIO component renders in a separate React root (isolated from page styles). This is a cosmetic issue — not yet fixed. + +### Pre-existing test failure + +`AccountWeb3 connect wallet calls createWeb3Wallet with a valid identifier` — this test fails because it tests through `MockClerkProvider` (not the composed providers) and the mock `ModuleManager` returns `undefined`. The test exercises the MetaMask `window.ethereum` path and is unrelated to the composed provider changes. + +## Decisions Made + +1. **`ui={ui}` required for composed components** — Accepted tradeoff. Users of composed components already depend on `@clerk/ui`, so importing `ui` and passing it is a one-line addition. This avoids needing clerk-js changes. + +2. **Module-level singleton over Clerk instance getter** — The singleton approach works immediately without a clerk-js release. The Clerk instance getter would be more robust (works without `ui={ui}`) but requires a publish cycle to take effect. + +3. **`createComposedRouter` delegates everything to `clerk.navigate`** — Rather than reimplementing same-origin detection, we delegate all navigation strings to `clerk.navigate` which already handles every case (external, same-origin via framework router, fallback to `window.location`). + +4. **Fallback `moduleManager` returns `undefined` silently** — When `ui={ui}` is not passed, features that need dynamic imports (wallet connect, password strength) silently degrade. No error is thrown. This matches the pre-existing behavior. + +## Test Coverage Gaps + +The current tests use `bindCreateFixtures` / `MockClerkProvider`, which provides its own mock `ModuleManager`. This means: + +- **Not tested through the actual composed providers**: Tests render section components directly with `MockClerkProvider`, not through `UserProfile.Provider`. The `moduleManagerStore` path is not exercised by any test. +- **No integration test for `ui={ui}` → `setModuleManager` → composed provider flow**: We verified this manually in the playground but there's no automated test. +- **Navigation via `createComposedRouter`**: The `stub-limitations.test.ts` tests verify `createComposedRouter` delegates to `clerkNavigate`, but don't test the full flow (e.g. clicking "Leave organization" → `clerk.navigate('/')` is called). +- **No test for the fallback path**: When `getModuleManager()` returns `undefined`, the fallback should be used. No test verifies this graceful degradation. +- **Spinner layout shift**: No visual regression test covers the styling difference between composed and AIO rendering. + +### What would improve confidence + +1. A test that renders through `UserProfileProvider` (not `MockClerkProvider`) and verifies `useModuleManager()` returns the real instance when `setModuleManager` has been called +2. A test that clicks a wallet connect button through the composed provider and verifies the Coinbase SDK flow is initiated (mocking `moduleManager.import` to return a fake SDK) +3. A test for the leave/delete org flow through the composed provider that verifies `clerk.navigate` is called with the right path diff --git a/packages/ui/src/composed/COMPONENT_INJECTION.md b/packages/ui/src/composed/COMPONENT_INJECTION.md new file mode 100644 index 00000000000..bf306710364 --- /dev/null +++ b/packages/ui/src/composed/COMPONENT_INJECTION.md @@ -0,0 +1,230 @@ +# Component Injection + +Pass custom primitive components to `` to replace Clerk's built-in UI primitives across all Clerk components. + +## Usage + +```tsx +import { ClerkProvider } from '@clerk/nextjs' + + + + +``` + +Every Clerk component (``, ``, ``, composed components, modals, drawers) will use your components instead of Clerk's defaults. + +## Available slots + +| Slot | What it replaces | Element type | +| --------- | ---------------------------------------------------------------------- | ------------ | +| `button` | All buttons (primary actions, secondary actions, social buttons, etc.) | ` + ); +}); +``` + +## Ref forwarding + +Custom components should forward refs for focus management and accessibility to work correctly: + +```tsx +const MyInput = React.forwardRef>((props, ref) => ( + +)); +``` + +## What is NOT replaceable + +- **Layout primitives** (`Box`, `Flex`, `Grid`) — these are structural, not user-facing +- **Typography** (`Text`, `Heading`, `Link`) — use the `appearance` prop for text styling +- **Cards, Modals, Drawers** — use the `appearance` prop for container styling +- **Section-level components** — the composed API (`UserProfile.AccountEmails`, etc.) handles section customization diff --git a/packages/ui/src/mosaic/MOSAIC_COMPARISON.md b/packages/ui/src/mosaic/MOSAIC_COMPARISON.md new file mode 100644 index 00000000000..d1fefeb609d --- /dev/null +++ b/packages/ui/src/mosaic/MOSAIC_COMPARISON.md @@ -0,0 +1,340 @@ +# Mosaic: CSS Modules vs Pigment CSS vs Tailwind CSS v4 vs Current Emotion System + +## Executive Summary + +All three Mosaic approaches eliminate runtime CSS-in-JS in favor of static CSS output. They differ in authoring model, build complexity, distribution model, and customization depth. This document compares all four systems across the dimensions that matter most for Clerk's UI library. + +--- + +## Side-by-Side Comparison + +| Dimension | Current (Emotion) | CSS Modules | Pigment CSS | Tailwind CSS v4 | +| ---------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| **Style authoring** | JS objects in component files via `css`/`sx` props | Separate `.module.css` files | JS objects in component files via `styled()` | Utility classes in JSX or `@apply` in CSS files | +| **Style output** | Runtime ` - - -
- - - diff --git a/playground/composed/package.json b/playground/composed/package.json deleted file mode 100644 index 072b97a05b9..00000000000 --- a/playground/composed/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "playground-composed", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "dev": "vite" - }, - "dependencies": { - "@clerk/react": "workspace:*", - "@clerk/ui": "workspace:*", - "react": "18.3.1", - "react-dom": "18.3.1" - }, - "devDependencies": { - "@types/react": "18.3.28", - "@types/react-dom": "18.3.7", - "@vitejs/plugin-react": "^4.3.4", - "typescript": "5.8.3", - "vite": "^6.0.0" - } -} diff --git a/playground/composed/src/App.tsx b/playground/composed/src/App.tsx deleted file mode 100644 index a8e8eb94d99..00000000000 --- a/playground/composed/src/App.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { Show, SignInButton, UserButton } from '@clerk/react'; -import { UserProfile, OrganizationProfile } from '@clerk/ui/experimental'; -import { useState } from 'react'; - -type ProfileType = 'user' | 'organization'; -type UserTab = 'account' | 'security' | 'billing' | 'api-keys'; -type OrgTab = 'general' | 'members' | 'billing' | 'api-keys' | 'configure-sso'; -type ComposedMode = 'passthrough' | 'composed'; - -export function App() { - const [profileType, setProfileType] = useState('user'); - const [userTab, setUserTab] = useState('account'); - const [orgTab, setOrgTab] = useState('general'); - const [composedMode, setComposedMode] = useState('passthrough'); - - return ( -
- -
-

Experimental Composed Profiles

- -
- -
- - -
- -
- - -
- - {profileType === 'user' && ( - - - {composedMode === 'passthrough' ? ( - <> - {userTab === 'account' && } - {userTab === 'security' && } - {userTab === 'billing' && } - {userTab === 'api-keys' && } - - ) : ( - <> - {userTab === 'account' && ( - - -
hello world
- - - - -
- )} - {userTab === 'security' && ( - - - - - - - - )} - {userTab === 'billing' && } - {userTab === 'api-keys' && } - - )} -
- )} - - {profileType === 'organization' && ( - - - {composedMode === 'passthrough' ? ( - <> - {orgTab === 'general' && } - {orgTab === 'members' && } - {orgTab === 'billing' && } - {orgTab === 'api-keys' && } - {orgTab === 'configure-sso' && } - - ) : ( - <> - {orgTab === 'general' && ( - - - - - - - )} - {orgTab === 'members' && } - {orgTab === 'billing' && } - {orgTab === 'api-keys' && } - {orgTab === 'configure-sso' && } - - )} - - )} - - } - > -

Experimental Composed Profiles Playground

-

Sign in to test the composed UserProfile and OrganizationProfile components.

- -
-
- ); -} - -function TabBar({ tabs, active, onChange }: { tabs: T[]; active: T; onChange: (tab: T) => void }) { - return ( -
- {tabs.map(tab => ( - - ))} -
- ); -} diff --git a/playground/composed/src/main.tsx b/playground/composed/src/main.tsx deleted file mode 100644 index d9633163e7d..00000000000 --- a/playground/composed/src/main.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { ClerkProvider } from '@clerk/react'; -import { ui } from '@clerk/ui'; -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; - -import { App } from './App'; - -const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; - -if (!publishableKey) { - throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY in .env.local'); -} - -createRoot(document.getElementById('root')!).render( - - - - - , -); diff --git a/playground/composed/src/vite-env.d.ts b/playground/composed/src/vite-env.d.ts deleted file mode 100644 index 91af686e054..00000000000 --- a/playground/composed/src/vite-env.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -/// - -interface ImportMetaEnv { - readonly VITE_CLERK_PUBLISHABLE_KEY: string; -} - -interface ImportMeta { - readonly env: ImportMetaEnv; -} diff --git a/playground/composed/tsconfig.json b/playground/composed/tsconfig.json deleted file mode 100644 index f003d97f308..00000000000 --- a/playground/composed/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "jsx": "react-jsx", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/playground/composed/vite.config.ts b/playground/composed/vite.config.ts deleted file mode 100644 index fabde1a8f5e..00000000000 --- a/playground/composed/vite.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; - -export default defineConfig({ - plugins: [react()], -}); From 716d04e65bdd42e932cc3123e090a2da18abdee4 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 29 May 2026 10:17:16 -0400 Subject: [PATCH 19/24] cleanup --- packages/ui/COMPOSED_API_PLAN.md | 753 --------------------------- packages/ui/MOSAIC.md | 845 ------------------------------- 2 files changed, 1598 deletions(-) delete mode 100644 packages/ui/COMPOSED_API_PLAN.md delete mode 100644 packages/ui/MOSAIC.md diff --git a/packages/ui/COMPOSED_API_PLAN.md b/packages/ui/COMPOSED_API_PLAN.md deleted file mode 100644 index e3fdb1cfe7d..00000000000 --- a/packages/ui/COMPOSED_API_PLAN.md +++ /dev/null @@ -1,753 +0,0 @@ -# Composed Profile API — Design Plan - -## Context - -The `@clerk/ui/experimental` export provides composable profile subcomponents that render outside Clerk's portal infrastructure. The current API uses named components (`UserProfile.Account`, `UserProfile.Security`) that render full page components. We're replacing this with a `Page`/`Section` API that gives consumers full compositional control: omit sections, reorder them, and inject custom content between them. - -## API - -### Import - -```tsx -import { UserProfile, OrganizationProfile } from '@clerk/ui/experimental'; -``` - -### Basic usage — full defaults - -```tsx - - - - - - -``` - -Each `Page` with no children renders the **full default page**: header, error alert, all built-in sections (respecting environment flags). - -**Rendered output for ``:** - -``` -┌──────────────────────────────────┐ -│ Account (h2) │ -│ │ -│ [Card.Alert - errors show here] │ -│ │ -│ ┌─ Profile Section ────────────┐ │ -│ │ Avatar, name, update button │ │ -│ └──────────────────────────────┘ │ -│ ┌─ Username Section ───────────┐ │ -│ │ (if username enabled) │ │ -│ └──────────────────────────────┘ │ -│ ┌─ Email Section ──────────────┐ │ -│ │ (if email enabled) │ │ -│ └──────────────────────────────┘ │ -│ ┌─ Phone Section ──────────────┐ │ -│ │ (if phone enabled) │ │ -│ └──────────────────────────────┘ │ -│ ┌─ Connected Accounts ─────────┐ │ -│ │ (if social providers) │ │ -│ └──────────────────────────────┘ │ -│ ┌─ Enterprise Accounts ────────┐ │ -│ │ (if enterprise SSO enabled) │ │ -│ └──────────────────────────────┘ │ -│ ┌─ Web3 Wallets ──────────────┐ │ -│ │ (if web3 enabled) │ │ -│ └──────────────────────────────┘ │ -└──────────────────────────────────┘ -``` - -### Section-level composition - -```tsx - - - - - - -``` - -When children are passed, the `Page` still renders the **header** and **Card.Alert** (error display), but children control the section layout below. No ProfileCard.Page padding wrapper. - -**Rendered output:** - -``` -┌──────────────────────────────────┐ -│ Account (h2) │ -│ │ -│ [Card.Alert - errors show here] │ -│ │ -│ ┌─ Profile Section ────────────┐ │ -│ │ Avatar, name, update button │ │ -│ └──────────────────────────────┘ │ -│ ┌─ Email Section ──────────────┐ │ -│ │ Email list, add button │ │ -│ └──────────────────────────────┘ │ -└──────────────────────────────────┘ -``` - -Header and error alert are always present. No phone, username, connected accounts, web3 sections — only what's declared. - -### Custom content injection - -```tsx - - - -
Verify your email to unlock features
- - -
-
-``` - -**Rendered output:** - -``` -┌──────────────────────────────────┐ -│ Account (h2) │ -│ │ -│ [Card.Alert - errors show here] │ -│ │ -│ ┌─ Profile Section ────────────┐ │ -│ │ Avatar, name, update button │ │ -│ └──────────────────────────────┘ │ -│ │ -│ ┌─ my-banner ──────────────────┐ │ -│ │ Verify your email to unlock │ │ -│ │ features │ │ -│ └──────────────────────────────┘ │ -│ │ -│ ┌─ Email Section ──────────────┐ │ -│ │ Email list, add button │ │ -│ └──────────────────────────────┘ │ -│ ┌─ Phone Section ──────────────┐ │ -│ │ Phone list, add button │ │ -│ └──────────────────────────────┘ │ -└──────────────────────────────────┘ -``` - -Header and Card.Alert always present. Children render in declaration order below. `Section` resolves to built-in UI. Everything else passes through as-is. - -### Custom pages - -```tsx - - - - - - -``` - -`Page` with `title` (no `id`) renders a custom page. Children are the page content. `title` and `id` are mutually exclusive. - -**Rendered output:** - -``` -Page 1: -┌──────────────────────────────────┐ -│ Account │ -│ [full default account page] │ -└──────────────────────────────────┘ - -Page 2: -┌──────────────────────────────────┐ -│ [MyPreferencesPanel renders] │ -└──────────────────────────────────┘ -``` - -### OrganizationProfile - -```tsx - - - - - - -``` - -Section composition on the general page: - -```tsx - - - - - - -``` - ---- - -## Behavior Rules - -### 1. No children = passthrough to existing component - -`` renders the existing `AccountPage` component directly. The Page adds nothing — `AccountPage` already provides its own header, `Card.Alert`, `CardStateProvider`, and all sections with environment guards. This is a zero-change passthrough. - -### 2. Any children = Page provides chrome + children control sections - -If you pass ANY children, the Page renders: - -- `CardStateProvider` (shared error state) -- `PageContext.Provider` (tells Section which page it's inside) -- Header (localized page title, styled as h2) -- `Card.Alert` (error display) -- Flex column with standard gap (`space.$8`) -- Children (Sections + custom content) - -The existing page component is NOT rendered. The Page component builds the chrome from scratch. - -### 3. Environment guards always respected - -`
` renders nothing (`null`) if `email_address` is not enabled in the Clerk dashboard. Guards are enforced in the `Section` wrapper, matching the logic currently in the parent page component. - -### 4. Loose section typing - -`Section` accepts any valid section ID from a flat union. If the ID doesn't match the parent page's type, it renders nothing (`null`). No runtime error, no DOM output. - -```tsx -// Renders nothing — 'password' isn't an account section - - - -``` - -### 5. Section resolution via PageContext - -`Page` provides a `PageContext` with the page ID. `Section` reads this context to look up the correct component from the section registry. This resolves ambiguity where the same section ID (e.g., `profile`) maps to different components depending on the page type. - -```tsx -// Inside : Section id="profile" → UserProfileSection -// Inside : Section id="profile" → OrganizationProfileSection -``` - -### 6. Atomic pages - -These pages don't support section composition — children are ignored: - -| Page ID | Reason | -| ---------- | ------------------------------------------------------------ | -| `members` | Single component with tabs, shared pagination, role fetching | -| `api-keys` | Single component, no discrete sections | - -### 7. Billing page — composable with managed navigation - -`` supports section composition AND manages sub-page navigation internally. The Page always provides the billing router (`useBillingRouter`). When the user is on the main view, sections render. When a section triggers navigation (e.g., "Switch plans"), the Page swaps the entire view to the sub-page (plans, statement detail, payment detail). Back navigation returns to the section view. - -**Default (no children) — passthrough to existing BillingPage with tabs:** - -``` - - -Renders the existing BillingPage (with tabs + sub-page navigation): -┌──────────────────────────────────────┐ -│ Billing (h2) │ -│ │ -│ [Subscriptions] [Statements] [Payments] -│ │ -│ ┌─ Current Plan ──────────────────┐ │ -│ │ Pro Plan - $20/mo │ │ -│ │ [Switch plans] │ │ -│ └─────────────────────────────────┘ │ -│ ┌─ Payment Methods ───────────────┐ │ -│ │ Visa •••• 4242 │ │ -│ └─────────────────────────────────┘ │ -└──────────────────────────────────────┘ -``` - -**Custom sections — no tabs, sections stack vertically. Consumer can add their own tab UI if desired:** - -```tsx - -
-
- -``` - -``` -Renders: -┌──────────────────────────────────────┐ -│ Billing (h2) │ -│ │ -│ ┌─ Subscriptions ─────────────────┐ │ -│ │ Pro Plan - $20/mo │ │ -│ │ [Switch plans] │ │ -│ └─────────────────────────────────┘ │ -│ ┌─ Payment Methods ───────────────┐ │ -│ │ Visa •••• 4242 │ │ -│ └─────────────────────────────────┘ │ -└──────────────────────────────────────┘ -``` - -**Sub-page navigation — managed by the Page:** - -``` -User clicks "Switch plans" in subscriptions → -Section calls navigate('plans') → -useBillingRouter updates route to { page: 'plans' } → -Page swaps entire view to PlansPage: - -┌──────────────────────────────────────┐ -│ ← Plans (h2) │ -│ │ -│ ┌─ Pricing Table ─────────────────┐ │ -│ │ Free Pro Enterprise │ │ -│ │ $0/mo $20/mo $50/mo │ │ -│ └─────────────────────────────────┘ │ -└──────────────────────────────────────┘ - -User clicks back → -useBillingRouter resets to { page: 'billing' } → -Page swaps back to sections view -``` - -Same pattern for statement detail and payment detail — sections trigger navigation, Page manages the view swap. - -**Billing section IDs:** - -| Section ID | Component | Router dependency | -| ---------------- | --------------------- | ------------------------------------------------------------ | -| `subscriptions` | `SubscriptionsList` | `navigate('plans')` — triggers sub-page swap | -| `paymentMethods` | `PaymentMethods` | None — fully standalone | -| `statements` | `StatementsList` | `navigate('statement/${id}')` — triggers sub-page swap | -| `payments` | `PaymentAttemptsList` | `navigate('payment-attempt/${id}')` — triggers sub-page swap | - -### 8. Page-level CardState - -When Page has children, it provides a shared `CardStateProvider`. Sections that call `useCardState()` write errors to this shared state, displayed in the `Card.Alert` the Page renders. When Page has no children (passthrough), the existing page component manages its own `CardStateProvider`. - -### 9. Custom page headers - -Custom pages (``) render a header with the title string, styled identically to built-in page headers (h2 variant). Built-in page headers use localization keys. - -### 10. Appearance prop - -`Provider` accepts an `appearance` prop for visual customization, passed through to `AppearanceProvider`. Same as current implementation. - ---- - -## Section Registry - -### UserProfile — Account page sections - -| Section ID | Component | Environment guard | Props injected by wrapper | -| -------------------- | --------------------------- | ------------------------------------------- | -------------------------------------------- | -| `profile` | `UserProfileSection` | none | none | -| `username` | `UsernameSection` | `attributes.username?.enabled` | `isImmutable` | -| `emails` | `EmailsSection` | `attributes.email_address?.enabled` | `shouldAllowCreation`, `shouldAllowDeletion` | -| `phone` | `PhoneSection` | `attributes.phone_number?.enabled` | `shouldAllowCreation`, `shouldAllowDeletion` | -| `connectedAccounts` | `ConnectedAccountsSection` | social providers exist with `enabled: true` | `shouldAllowCreation` | -| `enterpriseAccounts` | `EnterpriseAccountsSection` | `enterpriseSSO.enabled` | none | -| `web3` | `Web3Section` | `attributes.web3_wallet?.enabled` | `shouldAllowCreation` | - -Props are computed from `useEnvironment()` and `useUserProfileContext()` inside the Section wrapper. - -### UserProfile — Security page sections - -| Section ID | Component | Environment guard | Props injected by wrapper | -| --------------- | ---------------------- | -------------------------------------------------------- | ------------------------- | -| `password` | `PasswordSection` | `instanceIsPasswordBased` | none | -| `passkeys` | `PasskeySection` | passkeys enabled AND `shouldAllowIdentificationCreation` | none | -| `mfa` | `MfaSection` | `getSecondFactors(attributes).length > 0` | none | -| `activeDevices` | `ActiveDevicesSection` | none (always rendered) | none | -| `delete` | `DeleteSection` | `user.deleteSelfEnabled` | none | - -### OrganizationProfile — General page sections - -| Section ID | Component | Environment guard | Wrapper extras | -| ---------- | ---------------------------- | ---------------------------------------------------------- | -------------------------------------------------------- | -| `profile` | `OrganizationProfileSection` | none | none | -| `domains` | `OrganizationDomainsSection` | `organizationSettings.domains.enabled` | Wrapped in `` | -| `leave` | `OrganizationLeaveSection` | none | none | -| `delete` | `OrganizationDeleteSection` | `org:sys_profile:delete` permission + `adminDeleteEnabled` | none (guards are internal) | - ---- - -## Type Definitions - -```ts -// --- Page IDs --- - -type UserProfilePageId = 'account' | 'security' | 'billing' | 'api-keys'; -type OrganizationProfilePageId = 'general' | 'members' | 'billing' | 'api-keys'; - -// --- Section IDs --- - -type UserProfileSectionId = - // Account - | 'profile' - | 'username' - | 'emails' - | 'phone' - | 'connectedAccounts' - | 'enterpriseAccounts' - | 'web3' - // Security - | 'password' - | 'passkeys' - | 'mfa' - | 'activeDevices' - | 'delete' - // Billing - | 'subscriptions' - | 'paymentMethods' - | 'statements' - | 'payments'; - -type OrganizationProfileSectionId = - // General - | 'profile' - | 'domains' - | 'leave' - | 'delete' - // Billing (same IDs as UserProfile) - | 'subscriptions' - | 'paymentMethods' - | 'statements' - | 'payments'; - -// --- Component Props --- - -type PageProps = - | { id: UserProfilePageId; title?: never; children?: React.ReactNode } - | { id?: never; title: string; children: React.ReactNode }; - -type SectionProps = { - id: UserProfileSectionId; // or OrganizationProfileSectionId for org -}; -``` - ---- - -## Implementation - -### `Section` wrapper (conceptual) - -Each Section wrapper handles: environment guard, prop computation, and delegation to the existing component. - -```tsx -// Simplified example for the 'emails' section -function EmailsSectionWrapper() { - const { attributes } = useEnvironment().userSettings; - const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext(); - - // Environment guard — matches AccountPage line 25 - if (!attributes.email_address?.enabled) { - return null; - } - - const isImmutable = immutableAttributes.has('email_address'); - - return ( - - ); -} -``` - -The actual `Section` component uses a registry to look up the right wrapper: - -```tsx -const userAccountSections: Record = { - profile: ProfileSectionWrapper, - username: UsernameSectionWrapper, - emails: EmailsSectionWrapper, - phone: PhoneSectionWrapper, - connectedAccounts: ConnectedAccountsSectionWrapper, - enterpriseAccounts: EnterpriseAccountsSectionWrapper, - web3: Web3SectionWrapper, -}; - -const userSecuritySections: Record = { - password: PasswordSectionWrapper, - passkeys: PasskeySectionWrapper, - mfa: MfaSectionWrapper, - activeDevices: ActiveDevicesSectionWrapper, - delete: DeleteSectionWrapper, -}; -``` - -### `Page` component (conceptual) - -```tsx -function UserProfilePage({ id, title, children }: PageProps) { - // Custom page — just render children - if (title) { - return <>{children}; - } - - // Atomic pages — ignore children, render full component - if (id === 'api-keys') { - return ; - } - if (id === 'members') { - return ; // (org only) - } - - // Billing — composable with managed sub-page navigation - if (id === 'billing') { - const { router, route } = useBillingRouter(); - - // Sub-page active — Page takes over the whole view - if (route.page !== 'billing') { - return ( - - - - ); - } - - // Main billing view — section composition - return ( - - - - - {children ?? } - - - ); - } - - // Composable pages (account, security, general) - if (children) { - // Page provides chrome; children control section layout - return ( - - - ({ gap: t.space.$8 })}> - - - {children} - - - - ); - } - - // No children — passthrough to existing page component - // AccountPage/SecurityPage/etc. already have their own header, alert, CardState - return ; -} -``` - -### `DefaultPageRenderer` — renders the full default page - -When no children are passed, this renders the existing page component as-is (AccountPage, SecurityPage, OrganizationGeneralPage). This is what we have today — zero behavioral change for the simple case. - -```tsx -function DefaultPageRenderer({ id }: { id: string }) { - switch (id) { - case 'account': - return ; - case 'security': - return ; - case 'general': - return ; - default: - return null; - } -} -``` - ---- - -## File Changes - -### New files - -| File | Purpose | -| ------------------------------------------------------ | ------------------------------------------------------------------------------------------ | -| `src/composed/UserProfile/Page.tsx` | `UserProfile.Page` component with section registry, default rendering, children detection | -| `src/composed/UserProfile/Section.tsx` | `UserProfile.Section` component — looks up wrapper from registry, renders built-in section | -| `src/composed/UserProfile/sectionWrappers.tsx` | Section wrapper components that compute props + guards for each section | -| `src/composed/OrganizationProfile/Page.tsx` | `OrganizationProfile.Page` — same pattern | -| `src/composed/OrganizationProfile/Section.tsx` | `OrganizationProfile.Section` | -| `src/composed/OrganizationProfile/sectionWrappers.tsx` | Org section wrappers | - -### Modified files - -| File | Change | -| ---------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| `src/composed/UserProfile/index.tsx` | Replace `.Account`/`.Security`/`.Billing`/`.APIKeys` with `.Page` + `.Section` | -| `src/composed/OrganizationProfile/index.tsx` | Replace `.General`/`.Members`/`.Billing`/`.APIKeys` with `.Page` + `.Section` | -| `src/components/OrganizationProfile/OrganizationGeneralPage.tsx` | Export the four private section components | -| `playground/composed/src/App.tsx` | Update to use new Page/Section API | - -### Deleted files - -| File | Reason | -| ---------------------------------------------- | ------------------------------------------------------------------ | -| `src/composed/UserProfile/Account.tsx` | Replaced by `Page` + section wrappers | -| `src/composed/UserProfile/Security.tsx` | Replaced by `Page` + section wrappers | -| `src/composed/UserProfile/Billing.tsx` | Replaced by billing logic inside `Page` + billing section wrappers | -| `src/composed/OrganizationProfile/General.tsx` | Replaced by `Page` + section wrappers | -| `src/composed/OrganizationProfile/Members.tsx` | Replaced by `Page` (atomic, rendered directly) | -| `src/composed/OrganizationProfile/Billing.tsx` | Replaced by billing logic inside `Page` + billing section wrappers | - -### Kept as-is - -| File | Reason | -| ------------------------------------------------------------------ | ----------------------------------------------------- | -| `src/composed/UserProfile/APIKeys.tsx` | API Keys is atomic — `Page` delegates to this | -| `src/composed/OrganizationProfile/APIKeys.tsx` | Same | -| `src/composed/useBillingRouter.ts` | Still needed for billing sub-navigation inside `Page` | -| `src/composed/stubRouter.ts` | Still needed for non-billing pages | -| `src/composed/UserProfile/UserProfileProvider.tsx` | Provider unchanged | -| `src/composed/OrganizationProfile/OrganizationProfileProvider.tsx` | Provider unchanged | - ---- - -## Compound Export Shape - -```tsx -// src/composed/UserProfile/index.tsx -export const UserProfile = { - Provider: UserProfileProvider, - Page: UserProfilePage, - Section: UserProfileSection, -}; - -// src/composed/OrganizationProfile/index.tsx -export const OrganizationProfile = { - Provider: OrganizationProfileProvider, - Page: OrganizationProfilePage, - Section: OrganizationProfileSection, -}; -``` - ---- - -## Full Example — Everything Together - -```tsx -import { UserProfile, OrganizationProfile } from '@clerk/ui/experimental'; - -function MyApp() { - return ( -
- {/* Tab: Profile */} - - - - - - - - - - {/* Tab: Security — full defaults */} - - - - - {/* Tab: Organization */} - - - - - - - - -
- ); -} -``` - ---- - -## Known Issues & Risks - -### 1. Guard logic duplication - -Section wrappers must replicate guard logic that currently lives in parent page components. Two sources of truth. - -**Examples:** - -- `DeleteSection` renders unconditionally — the guard `user.deleteSelfEnabled` lives only in `SecurityPage.tsx:25`. The section wrapper must add this guard. -- `PasskeySection` visibility depends on `shouldAllowIdentificationCreation` from `useUserProfileContext()` — computed in `SecurityPage.tsx:23`, not in the section itself. -- AccountPage computes `isEmailImmutable`, `isPhoneImmutable`, `isUsernameImmutable` from `useUserProfileContext().immutableAttributes` and passes derived props (`shouldAllowCreation`, `shouldAllowDeletion`) to child sections. - -**Risk:** If a guard changes in the page component (portal path), the section wrapper (composed path) drifts silently. No shared code, no compile-time check. - -**Mitigation options:** - -- Extract shared `useSectionConfig()` hooks that both the page component and the section wrapper call. -- Move guards into the section components themselves (bigger refactor, changes portal path behavior). - -### 2. CardState scoping is inconsistent across sections - -Sections fall into three categories, each with different error display behavior: - -| Category | Sections | Error behavior | -| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| **Self-contained** (own `withCardStateProvider` + own `Card.Alert`) | `ConnectedAccountsSection`, `Web3Section`, `EnterpriseAccountsSection` | Errors display inside the section. Page-level `Card.Alert` never sees them. | -| **Parent-dependent** (no provider, calls `useCardState()`) | `EmailsSection`, `PhoneSection`, `MfaSection` | Errors bubble to the nearest `CardStateProvider` — currently the page's. | -| **No card state** | `UserProfileSection`, `UsernameSection`, `PasswordSection`, `PasskeySection`, `ActiveDevicesSection`, `DeleteSection` | No error interaction at the section level. | - -**The problem:** When `Page` provides chrome (children mode), it wraps everything in a `CardStateProvider` + renders `Card.Alert`. This works for parent-dependent sections (emails, phone, mfa) — their errors show in the page alert. But self-contained sections (connected accounts, web3) have their own provider, so errors display in duplicate locations OR only inside the section. - -**Worse:** If a parent-dependent section like `EmailsSection` is rendered WITHOUT a parent `CardStateProvider` (e.g., directly under `Provider` without a `Page`), `useCardState()` will throw. - -**Decision needed:** Should we require all sections to be self-contained? Or enforce that sections only render inside a `Page`? - -### 3. Org section components are private - -All four section components in `OrganizationGeneralPage.tsx` are unexported module-private `const`s: - -- `OrganizationProfileSection` (line 88) -- `OrganizationDomainsSection` (line 137) -- `OrganizationLeaveSection` (line 186) -- `OrganizationDeleteSection` (line 232) - -We need to export them for the section registry. This changes the module's public API surface. - -### 4. Org leave/delete forms depend on navigation callback - -`ActionConfirmationPage.tsx:22` reads `useOrganizationProfileContext().navigateAfterLeaveOrganization` — a callback that navigates away after the user leaves or deletes an org. In the portal path, this navigates to a different route. In the composed path, there's no route to navigate to. - -**Decision needed:** What should happen after leaving/deleting an org in the composed path? Callback prop on `Provider`? No-op? The current `OrganizationProfileProvider` doesn't provide `navigateAfterLeaveOrganization`. - -### 5. `useUserProfileContext()` is expensive - -The hook (`contexts/components/UserProfile.ts`) calls `useSubscription()`, `useStatements()`, and computes `pages` (navbar routes) on every render. Every section wrapper that needs a simple boolean like `shouldAllowIdentificationCreation` triggers all of this. - -In the portal path this is fine — the page component calls it once. In the composed path with N independent section wrappers, it's called N times per render. - -**Mitigation:** Extract the guard-related values into a lighter hook (e.g., `useUserProfileGuards()`) that doesn't pull billing data or page routes. - -### 6. Billing sections only work inside billing Page - -Billing sections call `navigate('plans')`, `navigate('statement/${id}')`, etc. These are handled by `useBillingRouter` which only exists when `` is the parent. - -If someone puts `
` under ``: - -- `PageContext` says "account", so the section registry lookup would fail (subscriptions isn't an account section) → renders null. This is the designed behavior (Rule 4). - -But if someone puts billing sections under `` without children (passthrough mode), the existing BillingPage with tabs renders — section composition is ignored. Need to document this clearly. - -### 7. StrictMode compatibility - -We already found that `useSafeState` (used by `useLoadingStatus`) breaks in React 18 StrictMode (the `isMountedRef` is never reset after remount). We fixed it, but other hooks may have similar patterns. Each section that uses form submission, loading states, or action menus could be affected. The portal path doesn't use StrictMode, so these bugs only surface in the composed path. - -**The composed playground uses ``.** Any host app might too. We should audit `useSafeState` consumers for similar issues. - ---- - -## Verification - -1. **Default rendering**: `` renders identically to current `` -2. **Section omission**: `
` renders only the profile section -3. **Custom content**: Non-Section children render inline between sections -4. **Environment guards**: Sections hidden by dashboard config render nothing even when explicitly declared -5. **Atomic pages**: Billing tabs, sub-navigation (plans, statements, payments) all work -6. **Error handling**: Section errors surface via `useCardState()` (available when page provides CardStateProvider) -7. **Existing tests**: `pnpm turbo test --filter=@clerk/ui` — all portal-path tests pass unchanged -8. **Playground**: Update `playground/composed/` to exercise all patterns above diff --git a/packages/ui/MOSAIC.md b/packages/ui/MOSAIC.md deleted file mode 100644 index 2bf990bb8d5..00000000000 --- a/packages/ui/MOSAIC.md +++ /dev/null @@ -1,845 +0,0 @@ -# Mosaic — Clerk UI Design System Exploration - -Working document covering the Mosaic design system exploration: replacing Emotion CSS-in-JS with static CSS, evaluating styling approaches, composed API context, and component injection. - ---- - -## Table of Contents - -1. [Background](#background) -2. [Proposal: CSS Modules](#proposal-css-modules) -3. [Alternatives Evaluated](#alternatives-evaluated) -4. [Side-by-Side Comparison](#side-by-side-comparison) -5. [Design Tokens](#design-tokens) -6. [Component Pattern](#component-pattern) -7. [Build Pipeline](#build-pipeline) -8. [Customer Developer Flows](#customer-developer-flows) -9. [Agent-Driven Customization](#agent-driven-customization) -10. [Rollout Plan](#rollout-plan) -11. [Composed Profile API](#composed-profile-api) -12. [Component Injection](#component-injection) -13. [Speculative Changelog Post](#speculative-changelog-post) -14. [Impact on Other Parts of the Product](#impact-on-other-parts-of-the-product) - ---- - -## Background - -Clerk's UI components are styled using Emotion CSS-in-JS with a custom appearance cascade system. This system was designed for maximum customization flexibility — users can override design tokens, target 471 individual element keys (each with state and ID variants producing 1,000+ unique selectors), compose prebuilt themes, and pass CSS-in-JS objects through a JavaScript API. - -That flexibility has come at a measurable cost: - -**For Clerk developers (internal):** - -- The theming infrastructure spans **53 source files and 6,437 lines of code** across `customizables/`, `styledSystem/`, `foundations/`, `themes/`, and theming-related utilities. -- Adding a single new styleable component requires touching **5 separate files**: the `APPEARANCE_KEYS` array (471 entries), the `ElementsConfig` type (554 lines of type declarations), the component file itself, the HOC wrapping chain in `customizables/index.ts`, and the JSX usage with `elementDescriptor`. -- Every component passes through **3 HOC wrappers** (`sanitizeDomProps` → `makeLocalizable` → `makeCustomizable`) before reaching the DOM. Each wrapper is a `React.forwardRef` component that adds a layer to the React tree and runs logic on every render. -- The variant system (`createVariants`, 141 lines) is a fully bespoke implementation that mirrors what CVA provides — it uses an `Infinity proxy` trick to extract variant keys at definition time and performs runtime theme evaluation on every render. -- The class generation pipeline (`classGeneration.ts`, 207 lines) runs a 7-step string-mutation sequence on every render of every wrapped component, performing up to 24 object property lookups for a component with 2 descriptors in a 3-layer cascade. -- A developer must hold **11 distinct concepts** simultaneously to confidently add and customize a new element: the APPEARANCE_KEYS registry, ElementsConfig type system, descriptor auto-generation, HOC chain, createVariants config shape, ThemableCssProp dual forms, cascade ordering, the `__` selector convention, the dual color-scale pipeline, CSS variable indirection patterns, and Emotion's css/className prop separation. - -**For Clerk customers (external):** - -- The `Variables` type exposes 22 properties, some of which accept two completely different input shapes (e.g., `fontSize` can be a string or a `FontSizeScale` object with 5 keys). -- The `Elements` type accepts 471 base element keys, each expandable with `__state` and `__id` suffixes, producing a keyspace of thousands. Finding the right key requires trial-and-error or consulting documentation — the keys are not discoverable from the component itself. -- Customization requires JavaScript — users pass CSS objects through a JS `appearance` prop. This means customization lives in JS config files, not in CSS where most developers expect styling to happen. -- The color system auto-generates 15 lightness shades and 15 alpha variants from a single input color, using a runtime pipeline that includes a browser capability check (`cssSupports.modernColor()`) and a 373-line legacy HSLA fallback path. Users have no control over which shades are generated. - -**For AI agents (emerging consumer):** - -- The `appearance` prop is a deeply nested JavaScript object. An agent needs to understand the cascade (global → per-component-key → per-component), the `__` selector convention, and which of the 1,000+ valid element keys to target. -- There is no machine-readable schema of available customization options. Agents must parse TypeScript type definitions or rely on documentation. -- The JS-based customization API requires agents to modify application code (JSX props or configuration objects), not just CSS files. - ---- - -## Proposal: CSS Modules - -Replace the Emotion-based appearance cascade with a new set of primitive components ("Mosaic") that use **CSS Modules** for styling and **CSS custom properties** for theming. The result: components ship as a static CSS file that users import once (`import '@clerk/ui/mosaic.css'`), customize with plain CSS variable overrides, and target with stable `data-cl-slot` attributes. No JavaScript styling API, no runtime style computation, no appearance cascade. - -This eliminates the 6,400-line theming infrastructure, the 3-HOC wrapper chain, the 7-step class generation pipeline, and the runtime color scale computation. For customers, customization becomes "write CSS" — the most universal, stable, and agent-friendly interface available. For Clerk developers, adding a new component means writing a `.tsx` file and a `.module.css` file — two files, zero registries, zero HOCs. - -### Key Decisions - -| Decision | Choice | -| ------------------- | ----------------------------------------------------------------------- | -| CSS strategy | CSS Modules (build-time scoped) | -| Scope | New primitives only: Button, Input, Select | -| Location | `packages/ui/src/mosaic/` | -| Token namespace | `--clerk-*` CSS custom properties | -| Token granularity | Semantic only (`--clerk-color-primary-hover`, not numbered scales) | -| Token scoping | `:root` | -| Dark mode | `light-dark()` CSS function, controlled via `color-scheme` property | -| Variants | `data-*` attributes targeted by CSS attribute selectors | -| Component API | `className` + `ref` forwarding (standard React primitives) | -| Public selector API | `data-cl-slot` attributes (e.g., `data-cl-slot="button-root"`) | -| CSS delivery | Single `import '@clerk/ui/mosaic.css'` | -| Class naming | `generateScopedName` auto-prepends `cl-` (internal, not public API) | -| Emotion coexistence | Separate `tsconfig.mosaic.json` with `jsxImportSource: "react"` | -| User customization | Pure CSS — override `--clerk-*` vars, target `[data-cl-slot]` selectors | - -### Current vs. Mosaic: side-by-side - -| Dimension | Current (Emotion) | Mosaic (CSS Modules) | -| -------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------- | -| Files to add a new component | 5 (registry, type, component, HOC chain, JSX usage) | 2 (component, CSS file) | -| HOC wrappers per component | 3 | 0 | -| Theming infrastructure (files / lines) | 53 / 6,437 | ~5 / ~200 (tokens.css, build config, type declaration) | -| Concepts to learn | 11 | 3 (CSS Modules imports, `data-*` variants, `--clerk-*` tokens) | -| Runtime style computation | Every render | None | -| Customer customization API | JS `appearance` prop (471 element keys, `Variables` object) | CSS (override variables, target `data-cl-slot` selectors) | -| Dark mode | JS theme swap (`dark` theme object import) | CSS `color-scheme` property or `light-dark()` | -| Agent discoverability | Parse TypeScript types | Read `CUSTOMIZATION.md` or inspect DOM (`data-cl-slot` visible in DevTools) | -| Modern CSS features | Requires library support | Direct access (`@property`, `@layer`, `@scope`, `@container`, etc.) | -| SSR / RSC | Requires Emotion SSR setup | Works natively | -| Bundle impact | Emotion runtime (~12KB) + theme object + style computation code | Static CSS file (cacheable, no JS runtime) | - -### File Structure - -``` -packages/ui/src/mosaic/ -├── tokens.css # Design token CSS custom properties -├── Button.tsx # Button component -├── Button.module.css # Button styles -├── Input.tsx # Input component -├── Input.module.css # Input styles -├── Select.tsx # Select component -├── Select.module.css # Select styles -├── index.ts # Public exports -└── scripts/ - └── generate-customization-docs.ts # Parses mosaic.css → CUSTOMIZATION.md -``` - ---- - -## Alternatives Evaluated - -### Pigment CSS (zero-runtime CSS-in-JS) - -MUI's build-time CSS-in-JS extraction library. Offers colocated styles in JS (familiar to the team) and a built-in variant system, with build-time extraction to static CSS. - -**What it offers:** - -- Colocated styles in `.tsx` files (same DX as Emotion) -- Built-in `variants` array with prop matching -- Theme callbacks via `({ theme }) => ({})` — resolved at build time -- Dual distribution: pre-extracted CSS for default consumers, raw source for advanced users via `transformLibraries` -- `extendTheme({ cssVarPrefix: 'clerk' })` auto-generates `--clerk-*` CSS vars -- `colorSchemes` + `getSelector` for class-based dark mode (`.theme-light` / `.theme-dark`) - -**Why we deferred it:** - -- Pigment CSS is v0.0.31 (alpha) with no stability guarantees — a risky foundation for a design system -- Its JS object syntax cannot express modern CSS features like `@property`, `@layer`, `@scope`, or `@starting-style` without escape hatches -- The advanced customization path (consumer-side `transformLibraries` + plugin) adds significant setup friction -- CSS Modules achieve the same output (static CSS, zero runtime) with no new dependencies and full access to the CSS language - -
-Pigment CSS component pattern (Button example) - -```tsx -/** @jsxImportSource react */ -import { styled } from '@pigment-css/react'; - -export const Button = styled('button', { - name: 'ClerkButton', - slot: 'root', -})(({ theme }) => ({ - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - gap: theme.vars.spacing[2], - borderRadius: theme.vars.radii.md, - fontFamily: theme.vars.typography.fontSans, - fontWeight: theme.vars.typography.fontWeightMedium, - cursor: 'pointer', - border: 'none', - transition: 'background-color 0.15s, border-color 0.15s, color 0.15s', - - '&:focus-visible': { - outline: `2px solid ${theme.vars.colors.ring}`, - outlineOffset: '2px', - }, - '&:disabled': { - opacity: 0.5, - cursor: 'not-allowed', - }, - - variants: [ - { - props: { variant: 'solid' }, - style: { - background: theme.vars.colors.primary, - color: theme.vars.colors.primaryContrast, - '&:hover': { background: theme.vars.colors.primaryHover }, - }, - }, - { - props: { variant: 'outline' }, - style: { - background: 'transparent', - color: theme.vars.colors.fg, - border: `1px solid ${theme.vars.colors.border}`, - '&:hover': { background: theme.vars.colors.surface }, - }, - }, - ], -})); -``` - -Build pipeline: separate Vite config (`vite.mosaic.config.ts`) with `@pigment-css/vite-plugin`. Dependencies: `@pigment-css/react` (runtime) + `@pigment-css/vite-plugin` (build). - -
- -### Tailwind CSS v4 - -Tailwind v4's shift to CSS-first configuration (`@theme` replaces `tailwind.config.js`) and native CSS custom property output made it worth serious consideration — especially since many Clerk consumers already use Tailwind. - -**What it offers:** - -- `@theme` directive defines tokens in CSS and automatically generates both CSS custom properties on `:root` and utility classes (`bg-clerk-primary`, `rounded-clerk-md`) -- `@reference` directive lets CSS files use `@apply` without re-emitting Tailwind's full output — viable for component library CSS -- Zero runtime — compiled output is static CSS, same as CSS Modules -- Consumer theme unification — Tailwind consumers could import Clerk's `@theme` tokens and use them in their own components - -**Why we deferred it:** - -- **Distribution is unsolved.** Pre-compiled Tailwind CSS has `@layer` conflicts when dropped into a consumer app that also uses Tailwind v4 (documented issue, GitHub #17954, #18758). The source-distribution path requires every consumer to have Tailwind installed. There is no path that "just works" for all consumers — CSS Modules has no such problem. -- **No `light-dark()` support.** Tailwind's dark mode uses class toggling (`.dark`) or media queries, not the `color-scheme` property. This means two separate variable blocks (light + dark) instead of one `light-dark()` declaration per token — doubling the token maintenance surface. -- **Modern CSS features are second-class.** `@property`, `@scope`, `@starting-style` etc. work in CSS files alongside Tailwind, but can't be expressed via utility classes. You'd fall back to plain CSS for these features, creating a split authoring model. -- **Agent parseability.** `[data-cl-slot='button-root']` and `--clerk-color-primary` are semantic and self-documenting. `inline-flex items-center justify-center gap-2 rounded-md font-medium` describes physical styles, not a semantic identifier. Agents can target the former directly; they'd need to reverse-engineer the latter. -- **The `@apply` escape hatch negates the point.** If you use `@apply` in CSS Module files to avoid utility class strings in JSX, Tailwind is functioning as a preprocessor over CSS Modules — you get Tailwind's build complexity without its primary benefit. - -
-Tailwind v4 component pattern (Button example) - -```tsx -// Option 1: Utility classes in JSX -const variantClasses: Record = { - solid: 'bg-clerk-primary text-clerk-primary-contrast hover:bg-clerk-primary-hover border-transparent', - outline: 'bg-transparent text-clerk-fg border-clerk-border hover:bg-clerk-surface', - ghost: 'bg-transparent text-clerk-fg border-transparent hover:bg-clerk-surface', -}; - -export const Button = forwardRef( - ({ variant = 'solid', size = 'md', className, children, ...props }, ref) => ( - - ), -); -``` - -```css -/* Option 2: @apply in CSS Module */ -@reference "tailwindcss"; - -.root[data-variant='solid'] { - @apply bg-clerk-primary text-clerk-primary-contrast border-transparent; - &:hover { - @apply bg-clerk-primary-hover; - } -} -``` - -Token definition via `@theme` directive in CSS. Build pipeline: Vite + `@tailwindcss/vite`. Dependencies: `tailwindcss` + `@tailwindcss/vite` (build only). - -Distribution problem: pre-compiled CSS has `@layer` conflicts with consumer Tailwind. Source distribution requires consumer Tailwind. No clean path for all consumers. - -
- -### Keeping the `appearance` prop alongside CSS variables - -We considered maintaining a JavaScript `appearance` prop that would set CSS variables under the hood — a bridge for customers already using the current API. We deferred this because: - -- It adds a JS-to-CSS translation layer that re-introduces the complexity we're trying to remove -- CSS variables are strictly more universal — they work in JS frameworks, plain HTML, server components, and with AI agents -- A codemod that converts `appearance.variables` to CSS declarations is a simpler migration path - -### Trade-offs in one sentence - -- **CSS Modules** trades authoring familiarity for stability, future-proofing, and full CSS language access. -- **Pigment CSS** trades stability and CSS access for authoring familiarity. -- **Tailwind CSS v4** trades distribution simplicity and `light-dark()` for authoring speed and consumer theme unification. - ---- - -## Side-by-Side Comparison - -| Dimension | Current (Emotion) | CSS Modules | Pigment CSS | Tailwind CSS v4 | -| ----------------------- | ---------------------------------------- | ----------------------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------- | -| **Style authoring** | JS objects via `css`/`sx` props | Separate `.module.css` files | JS objects via `styled()` | Utility classes in JSX or `@apply` in CSS | -| **Style output** | Runtime `