diff --git a/.changeset/profile-section-components.md b/.changeset/profile-section-components.md new file mode 100644 index 00000000000..67fc1a590d7 --- /dev/null +++ b/.changeset/profile-section-components.md @@ -0,0 +1,6 @@ +--- +'@clerk/ui': minor +'@clerk/clerk-js': patch +--- + +Expose profile sub components for composable profile UIs diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index a762311e98e..0db0176f487 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -272,6 +272,7 @@ export class Clerk implements ClerkInterface { #pageLifecycle: ReturnType | null = null; #touchThrottledUntil = 0; #publicEventBus = createClerkEventBus(); + #moduleManager = new ModuleManager(); get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined { if (!this.#queryClient) { @@ -538,7 +539,7 @@ export class Clerk implements ClerkInterface { () => this, () => this.environment, this.#options, - new ModuleManager(), + this.#moduleManager, ), ); } diff --git a/packages/ui/package.json b/packages/ui/package.json index 698838a8a2a..29a96f608a6 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/ClerkUI.ts b/packages/ui/src/ClerkUI.ts index c6bee5da754..b9ed38415b7 100644 --- a/packages/ui/src/ClerkUI.ts +++ b/packages/ui/src/ClerkUI.ts @@ -9,6 +9,7 @@ import { isVersionAtLeast, parseVersion } from '@clerk/shared/versionCheck'; import { type MountComponentRenderer, mountComponentRenderer } from './Components'; import { MIN_CLERK_JS_VERSION } from './constants'; +import { setModuleManager } from './internal/moduleManagerStore'; /** * Core rendering engine for Clerk's prebuilt UI components. @@ -78,6 +79,7 @@ export class ClerkUI implements ClerkUIInstance { } } + setModuleManager(clerk, moduleManager); this.#componentRenderer = mountComponentRenderer(getClerk, getEnvironment, options, moduleManager); } 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' })} > { export const OrganizationGeneralPage = () => { return ( - ({ gap: t.space.$8 })} + - - - ({ marginBottom: t.space.$4 })} - textVariant='h2' - /> - - - - - - - - - + + + + + + + ); }; -const OrganizationProfileSection = () => { +export const OrganizationProfileSection = () => { const { organization } = useOrganization(); if (!organization) { @@ -134,7 +121,7 @@ const OrganizationProfileSection = () => { ); }; -const OrganizationDomainsSection = () => { +export const OrganizationDomainsSection = () => { const { organizationSettings } = useEnvironment(); const { organization } = useOrganization(); @@ -183,7 +170,7 @@ const OrganizationDomainsSection = () => { ); }; -const OrganizationLeaveSection = () => { +export const OrganizationLeaveSection = () => { const { organization } = useOrganization(); if (!organization) { @@ -229,7 +216,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/components/OrganizationProfile/OrganizationMembers.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx index 0b103b559e0..9109a5e2c1e 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx @@ -55,6 +55,7 @@ export const OrganizationMembers = withCardStateProvider(() => { { { - const { attributes, social, enterpriseSSO } = useEnvironment().userSettings; const card = useCardState(); - const { user } = useUser(); - const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext(); - - const showUsername = attributes.username?.enabled; - const showEmail = attributes.email_address?.enabled; - const showPhone = attributes.phone_number?.enabled; - const showConnectedAccounts = social && Object.values(social).filter(p => p.enabled).length > 0; - const showEnterpriseAccounts = user && enterpriseSSO.enabled; - const showWeb3 = attributes.web3_wallet?.enabled; - - const isEmailImmutable = immutableAttributes.has('email_address'); - const isPhoneImmutable = immutableAttributes.has('phone_number'); - const isUsernameImmutable = immutableAttributes.has('username'); return ( - ({ gap: t.space.$8, color: t.colors.$colorForeground })} + ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} > - - - ({ marginBottom: t.space.$4 })} - textVariant='h2' - /> - - - {card.error} - - - {showUsername && } - {showEmail && ( - - )} - {showPhone && ( - - )} - {showConnectedAccounts && ( - - )} - - {/*TODO-STEP-UP: Verify that these work as expected*/} - {showEnterpriseAccounts && } - {showWeb3 && } - - + + + + + + + + ); }); diff --git a/packages/ui/src/components/UserProfile/AccountSections.tsx b/packages/ui/src/components/UserProfile/AccountSections.tsx new file mode 100644 index 00000000000..83265888ebf --- /dev/null +++ b/packages/ui/src/components/UserProfile/AccountSections.tsx @@ -0,0 +1,77 @@ +import { useUser } from '@clerk/shared/react'; +import type { ReactNode } from 'react'; + +import { useEnvironment, useUserProfileContext } from '../../contexts'; +import { ConnectedAccountsSection } from './ConnectedAccountsSection'; +import { EmailsSection } from './EmailsSection'; +import { EnterpriseAccountsSection } from './EnterpriseAccountsSection'; +import { PhoneSection } from './PhoneSection'; +import { UsernameSection } from './UsernameSection'; +import { Web3Section } from './Web3Section'; + +export function AccountUsername(): ReactNode { + const { attributes } = useEnvironment().userSettings; + const { immutableAttributes } = useUserProfileContext(); + + if (!attributes.username?.enabled) return null; + + const isImmutable = immutableAttributes.has('username'); + return ; +} + +export function AccountEmails(): ReactNode { + 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(): ReactNode { + 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(): ReactNode { + const { social } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation } = useUserProfileContext(); + + if (!social || Object.values(social).filter(p => p.enabled).length === 0) return null; + + return ; +} + +export function AccountEnterpriseAccounts(): ReactNode { + const { enterpriseSSO } = useEnvironment().userSettings; + const { user } = useUser(); + + if (!user || !enterpriseSSO.enabled) return null; + + return ; +} + +export function AccountWeb3(): ReactNode { + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation } = useUserProfileContext(); + + if (!attributes.web3_wallet?.enabled) return null; + + return ; +} diff --git a/packages/ui/src/components/UserProfile/BillingPage.tsx b/packages/ui/src/components/UserProfile/BillingPage.tsx index 51976e905de..d76665d40db 100644 --- a/packages/ui/src/components/UserProfile/BillingPage.tsx +++ b/packages/ui/src/components/UserProfile/BillingPage.tsx @@ -26,7 +26,7 @@ const BillingPageInternal = withCardStateProvider(() => { ({ gap: t.space.$8, color: t.colors.$colorForeground })} + sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} > { - const { attributes, instanceIsPasswordBased } = useEnvironment().userSettings; const card = useCardState(); - const { user } = useUser(); - const { shouldAllowIdentificationCreation } = useUserProfileContext(); - const showPassword = instanceIsPasswordBased; - const showPasskey = attributes.passkey?.enabled && shouldAllowIdentificationCreation; - const showMfa = getSecondFactors(attributes).length > 0; - const showDelete = user?.deleteSelfEnabled; return ( - ({ gap: t.space.$8 })} + - - - ({ marginBottom: t.space.$4 })} - textVariant='h2' - /> - - {card.error} - {showPassword && } - {showPasskey && } - {showMfa && } - - {showDelete && } - - + + + + + + ); }); diff --git a/packages/ui/src/components/UserProfile/SecuritySections.tsx b/packages/ui/src/components/UserProfile/SecuritySections.tsx new file mode 100644 index 00000000000..a1638f8dd7a --- /dev/null +++ b/packages/ui/src/components/UserProfile/SecuritySections.tsx @@ -0,0 +1,43 @@ +import { useUser } from '@clerk/shared/react'; +import type { ReactNode } from 'react'; + +import { getSecondFactors } from '@/ui/utils/mfa'; + +import { useEnvironment, useUserProfileContext } from '../../contexts'; +import { MfaSection } from './MfaSection'; +import { PasskeySection } from './PasskeySection'; +import { PasswordSection } from './PasswordSection'; +import { DeleteSection } from './DeleteSection'; + +export function SecurityPassword(): ReactNode { + const { instanceIsPasswordBased } = useEnvironment().userSettings; + + if (!instanceIsPasswordBased) return null; + + return ; +} + +export function SecurityPasskeys(): ReactNode { + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation } = useUserProfileContext(); + + if (!attributes.passkey?.enabled || !shouldAllowIdentificationCreation) return null; + + return ; +} + +export function SecurityMfa(): ReactNode { + const { attributes } = useEnvironment().userSettings; + + if (getSecondFactors(attributes).length === 0) return null; + + return ; +} + +export function SecurityDelete(): ReactNode { + const { user } = useUser(); + + if (!user?.deleteSelfEnabled) return null; + + return ; +} diff --git a/packages/ui/src/components/UserProfile/__tests__/AccountSections.test.tsx b/packages/ui/src/components/UserProfile/__tests__/AccountSections.test.tsx new file mode 100644 index 00000000000..0fa787b7e7e --- /dev/null +++ b/packages/ui/src/components/UserProfile/__tests__/AccountSections.test.tsx @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; +import { CardStateProvider } from '@/ui/elements/contexts'; + +import { clearFetchCache } from '../../../hooks'; +import { + AccountUsername, + AccountEmails, + AccountPhone, + AccountConnectedAccounts, + AccountEnterpriseAccounts, + AccountWeb3, +} from '../AccountSections'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('AccountSections — self-gating visibility', () => { + beforeEach(() => { + clearFetchCache(); + }); + + describe('AccountUsername', () => { + it('renders when username is enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUsername(); + f.withUser({ email_addresses: ['test@clerk.com'], username: 'testuser' }); + }); + + render(, { wrapper }); + screen.getByText('testuser'); + }); + + it('returns null when username is disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('AccountEmails', () => { + it('renders email list when enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ email_addresses: ['a@clerk.com', 'b@clerk.com'] }); + }); + + render( + + + , + { wrapper }, + ); + screen.getByText('a@clerk.com'); + screen.getByText('b@clerk.com'); + }); + + it('returns null when email is disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('AccountPhone', () => { + it('renders phone list when enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber(); + f.withUser({ email_addresses: ['test@clerk.com'], phone_numbers: ['+11111111111'] }); + }); + + render( + + + , + { wrapper }, + ); + expect(screen.getAllByText(/phone number/i).length).toBeGreaterThan(0); + }); + + it('returns null when phone is disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('AccountConnectedAccounts', () => { + it('renders when social providers are enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + f.withUser({ + email_addresses: ['test@clerk.com'], + external_accounts: [{ provider: 'google', email_address: 'test@clerk.com' }], + }); + }); + + render(, { wrapper }); + screen.getByText(/connected accounts/i); + }); + + it('returns null when no social providers are enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('AccountEnterpriseAccounts', () => { + it('returns null when enterprise SSO is disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('AccountWeb3', () => { + it('renders when web3_wallet is enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withWeb3Wallet(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render(, { wrapper }); + screen.getByText(/web3 wallets/i); + }); + + it('returns null when web3_wallet is disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); +}); diff --git a/packages/ui/src/components/UserProfile/__tests__/SecuritySections.test.tsx b/packages/ui/src/components/UserProfile/__tests__/SecuritySections.test.tsx new file mode 100644 index 00000000000..707cf50b8f2 --- /dev/null +++ b/packages/ui/src/components/UserProfile/__tests__/SecuritySections.test.tsx @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; +import { CardStateProvider } from '@/ui/elements/contexts'; + +import { clearFetchCache } from '../../../hooks'; +import { SecurityPassword, SecurityPasskeys, SecurityMfa, SecurityDelete } from '../SecuritySections'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('SecuritySections — self-gating visibility', () => { + beforeEach(() => { + clearFetchCache(); + }); + + describe('SecurityPassword', () => { + it('renders when instance is password-based', async () => { + const { wrapper } = await createFixtures(f => { + f.withPassword(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + + + , + { wrapper }, + ); + screen.getByText(/^password/i); + }); + + it('returns null when instance is not password-based', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('SecurityPasskeys', () => { + it('renders when passkeys are enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withPasskey(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + + + , + { wrapper }, + ); + screen.getByText(/^passkeys/i); + }); + + it('returns null when passkeys are disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('SecurityMfa', () => { + it('renders when second factors are available', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber({ used_for_second_factor: true, second_factors: ['phone_code'] }); + f.withUser({ email_addresses: ['test@clerk.com'], phone_numbers: ['+11111111111'] }); + }); + + render( + + + , + { wrapper }, + ); + expect(screen.getAllByText(/two-step verification/i).length).toBeGreaterThan(0); + }); + + it('returns null when no second factors are available', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('SecurityDelete', () => { + it('renders when delete_self_enabled is true', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: true }); + }); + + render(, { wrapper }); + expect(screen.getAllByText(/delete account/i).length).toBeGreaterThan(0); + }); + + it('returns null when delete_self_enabled is false', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: false }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); +}); diff --git a/packages/ui/src/composed/APIKeysSection.tsx b/packages/ui/src/composed/APIKeysSection.tsx new file mode 100644 index 00000000000..fba85bf59b9 --- /dev/null +++ b/packages/ui/src/composed/APIKeysSection.tsx @@ -0,0 +1,13 @@ +import { Suspense, type ComponentType, type ReactNode } from 'react'; + +import { CardStateProvider } from '../elements/contexts'; + +export function APIKeysSection({ page: Page }: { page: ComponentType }): ReactNode { + return ( + + + + + + ); +} diff --git a/packages/ui/src/composed/BillingSection.tsx b/packages/ui/src/composed/BillingSection.tsx new file mode 100644 index 00000000000..13d3c2640b7 --- /dev/null +++ b/packages/ui/src/composed/BillingSection.tsx @@ -0,0 +1,41 @@ +import { Suspense, type ComponentType, type ReactNode } from 'react'; + +import { RouteContext } from '../router/RouteContext'; +import { useBillingRouter } from './useBillingRouter'; + +type BillingSectionProps = { + billing: ComponentType; + plans: ComponentType; + statement: ComponentType; + paymentAttempt: ComponentType; +}; + +export function BillingSection({ + billing: Billing, + plans: Plans, + statement: Statement, + paymentAttempt: PaymentAttempt, +}: BillingSectionProps): ReactNode { + const { router, route } = useBillingRouter(); + + let content: ReactNode; + switch (route.page) { + case 'plans': + content = ; + break; + case 'statement': + content = ; + break; + case 'payment-attempt': + content = ; + break; + default: + content = ; + } + + return ( + + {content} + + ); +} diff --git a/packages/ui/src/composed/OrganizationProfile/APIKeys.tsx b/packages/ui/src/composed/OrganizationProfile/APIKeys.tsx new file mode 100644 index 00000000000..71a5f6e2a30 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/APIKeys.tsx @@ -0,0 +1,11 @@ +import { lazy, type ReactNode } from 'react'; + +import { APIKeysSection } from '../APIKeysSection'; + +const OrganizationAPIKeysPage = lazy(() => + import('../../components/OrganizationProfile/OrganizationAPIKeysPage').then(m => ({ + default: m.OrganizationAPIKeysPage, + })), +); + +export const APIKeys = (): ReactNode => ; diff --git a/packages/ui/src/composed/OrganizationProfile/Billing.tsx b/packages/ui/src/composed/OrganizationProfile/Billing.tsx new file mode 100644 index 00000000000..a96ffd85127 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/Billing.tsx @@ -0,0 +1,36 @@ +import { lazy, type ReactNode } from 'react'; + +import { BillingSection } from '../BillingSection'; + +const OrganizationBillingPage = lazy(() => + import('../../components/OrganizationProfile/OrganizationBillingPage').then(m => ({ + default: m.OrganizationBillingPage, + })), +); + +const OrganizationPlansPage = lazy(() => + import('../../components/OrganizationProfile/OrganizationPlansPage').then(m => ({ + default: m.OrganizationPlansPage, + })), +); + +const OrganizationStatementPage = lazy(() => + import('../../components/OrganizationProfile/OrganizationStatementPage').then(m => ({ + default: m.OrganizationStatementPage, + })), +); + +const OrganizationPaymentAttemptPage = lazy(() => + import('../../components/OrganizationProfile/OrganizationPaymentAttemptPage').then(m => ({ + default: m.OrganizationPaymentAttemptPage, + })), +); + +export const Billing = (): ReactNode => ( + +); diff --git a/packages/ui/src/composed/OrganizationProfile/ConfigureSSO.tsx b/packages/ui/src/composed/OrganizationProfile/ConfigureSSO.tsx new file mode 100644 index 00000000000..a97cc59892d --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/ConfigureSSO.tsx @@ -0,0 +1,22 @@ +import { useRef, type ReactNode } from 'react'; + +import type { Elements } from '../../internal/appearance'; +import { ConfigureSSOContent } from '../../components/ConfigureSSO/ConfigureSSO'; +import { AppearanceOverrides } from '../../elements/AppearanceOverrides'; +import { CardStateProvider } from '../../elements/contexts'; + +const embeddedOverrides: Elements = { + configureSSOFooter: { background: 'transparent' }, +}; + +export const ConfigureSSO = (): ReactNode => { + const contentRef = useRef(null); + + return ( + + + + + + ); +}; diff --git a/packages/ui/src/composed/OrganizationProfile/General.tsx b/packages/ui/src/composed/OrganizationProfile/General.tsx new file mode 100644 index 00000000000..1f825f935e0 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/General.tsx @@ -0,0 +1,26 @@ +import type { PropsWithChildren, ReactNode } from 'react'; + +import { ProfileCard } from '@/ui/elements/ProfileCard'; + +import { localizationKeys } from '../../customizables'; +import { OrganizationGeneralPage } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { PageContext } from '../PageContext'; + +export function General({ children }: PropsWithChildren): ReactNode { + if (!children) { + return ; + } + + return ( + + + + {children} + + + + ); +} diff --git a/packages/ui/src/composed/OrganizationProfile/GeneralDeleteOrganization.tsx b/packages/ui/src/composed/OrganizationProfile/GeneralDeleteOrganization.tsx new file mode 100644 index 00000000000..366f2cba08f --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/GeneralDeleteOrganization.tsx @@ -0,0 +1,4 @@ +import { OrganizationDeleteSection } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { createSection } from '../createSection'; + +export const GeneralDeleteOrganization = createSection('GeneralDeleteOrganization', OrganizationDeleteSection); diff --git a/packages/ui/src/composed/OrganizationProfile/GeneralLeaveOrganization.tsx b/packages/ui/src/composed/OrganizationProfile/GeneralLeaveOrganization.tsx new file mode 100644 index 00000000000..d2b6d66c1d0 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/GeneralLeaveOrganization.tsx @@ -0,0 +1,4 @@ +import { OrganizationLeaveSection } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { createSection } from '../createSection'; + +export const GeneralLeaveOrganization = createSection('GeneralLeaveOrganization', OrganizationLeaveSection); diff --git a/packages/ui/src/composed/OrganizationProfile/GeneralOrganizationProfile.tsx b/packages/ui/src/composed/OrganizationProfile/GeneralOrganizationProfile.tsx new file mode 100644 index 00000000000..559a1a6cfed --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/GeneralOrganizationProfile.tsx @@ -0,0 +1,4 @@ +import { OrganizationProfileSection } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { createSection } from '../createSection'; + +export const GeneralOrganizationProfile = createSection('GeneralOrganizationProfile', OrganizationProfileSection); diff --git a/packages/ui/src/composed/OrganizationProfile/GeneralVerifiedDomains.tsx b/packages/ui/src/composed/OrganizationProfile/GeneralVerifiedDomains.tsx new file mode 100644 index 00000000000..40d2a24d45d --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/GeneralVerifiedDomains.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; + +import { Protect } from '../../common'; +import { OrganizationDomainsSection } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { useRequirePage } from '../useRequirePage'; + +export function GeneralVerifiedDomains(): ReactNode { + if (!useRequirePage('GeneralVerifiedDomains')) return null; + return ( + + + + ); +} diff --git a/packages/ui/src/composed/OrganizationProfile/Members.tsx b/packages/ui/src/composed/OrganizationProfile/Members.tsx new file mode 100644 index 00000000000..d99d2895e44 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/Members.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from 'react'; + +import { OrganizationMembers } from '../../components/OrganizationProfile/OrganizationMembers'; + +export const Members = (): ReactNode => ; diff --git a/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx b/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx new file mode 100644 index 00000000000..15860a65b43 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx @@ -0,0 +1,52 @@ +import { useClerk, useOrganization, useUser } from '@clerk/shared/react'; +import type { EnvironmentResource } from '@clerk/shared/types'; +import type { PropsWithChildren, ReactNode } from 'react'; + +import type { Appearance } from '@/ui/internal/appearance'; +import { getModuleManager } from '@/ui/internal/moduleManagerStore'; + +import { SubscriberTypeContext } from '../../contexts/components/SubscriberType'; +import { OrganizationProfileContext } from '../../contexts/components/OrganizationProfile'; +import { ProfileProviderShell, fallbackModuleManager } from '../ProfileProviderShell'; + +type OrganizationProfileProviderProps = PropsWithChildren<{ + appearance?: Appearance; +}>; + +export const OrganizationProfileProvider = (props: OrganizationProfileProviderProps): ReactNode => { + 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; + const moduleManager = getModuleManager(clerk) ?? fallbackModuleManager; + + 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..5bed9c9640d --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/index.tsx @@ -0,0 +1 @@ +export * as OrganizationProfile from './parts'; diff --git a/packages/ui/src/composed/OrganizationProfile/parts.ts b/packages/ui/src/composed/OrganizationProfile/parts.ts new file mode 100644 index 00000000000..5b832ec2100 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/parts.ts @@ -0,0 +1,10 @@ +export { OrganizationProfileProvider as Root } from './OrganizationProfileProvider'; +export { General } from './General'; +export { Members } from './Members'; +export { Billing } from './Billing'; +export { APIKeys } from './APIKeys'; +export { ConfigureSSO } from './ConfigureSSO'; +export { GeneralOrganizationProfile } from './GeneralOrganizationProfile'; +export { GeneralVerifiedDomains } from './GeneralVerifiedDomains'; +export { GeneralLeaveOrganization } from './GeneralLeaveOrganization'; +export { GeneralDeleteOrganization } from './GeneralDeleteOrganization'; diff --git a/packages/ui/src/composed/PageContext.tsx b/packages/ui/src/composed/PageContext.tsx new file mode 100644 index 00000000000..b16f319d803 --- /dev/null +++ b/packages/ui/src/composed/PageContext.tsx @@ -0,0 +1,5 @@ +import { createContext } from 'react'; + +type PageId = 'account' | 'security' | 'general'; + +export const PageContext = createContext(null); diff --git a/packages/ui/src/composed/ProfileProviderShell.tsx b/packages/ui/src/composed/ProfileProviderShell.tsx new file mode 100644 index 00000000000..e2f3eddcef2 --- /dev/null +++ b/packages/ui/src/composed/ProfileProviderShell.tsx @@ -0,0 +1,76 @@ +import type { ModuleManager } from '@clerk/shared/moduleManager'; +import type { EnvironmentResource, LoadedClerk } from '@clerk/shared/types'; +import type { PropsWithChildren, ReactNode } from 'react'; +import { useMemo } from 'react'; + +import { AppearanceProvider } from '@/ui/customizables/AppearanceContext'; +import { FlowMetadataProvider } from '@/ui/elements/contexts'; +import type { Appearance } 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 { AppearanceOverrides } from '../elements/AppearanceOverrides'; +import type { Elements } from '../internal/appearance'; +import { createComposedRouter } from './stubRouter'; + +export const fallbackModuleManager: ModuleManager = { + import: () => Promise.resolve(undefined) as any, +}; + +const composedOverrides: Elements = { + profilePageContent: { padding: 0 }, +}; + +type ProfileProviderShellProps = PropsWithChildren<{ + clerk: LoadedClerk; + environment: EnvironmentResource; + moduleManager: ModuleManager; + appearanceKey: 'userProfile' | 'organizationProfile'; + flow: 'userProfile' | 'organizationProfile'; + globalAppearance: Appearance | undefined; + appearance?: Appearance; +}>; + +export function ProfileProviderShell({ + children, + clerk, + environment, + moduleManager, + appearanceKey, + flow, + globalAppearance, + appearance, +}: ProfileProviderShellProps): ReactNode { + const router = useMemo(() => createComposedRouter(clerk.navigate), [clerk]); + + return ( + + + + + + + + + {children} + + + + + + + + + ); +} diff --git a/packages/ui/src/composed/UserProfile/APIKeys.tsx b/packages/ui/src/composed/UserProfile/APIKeys.tsx new file mode 100644 index 00000000000..aa099d0b2a3 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/APIKeys.tsx @@ -0,0 +1,11 @@ +import { lazy, type ReactNode } from 'react'; + +import { APIKeysSection } from '../APIKeysSection'; + +const APIKeysPage = lazy(() => + import('../../components/UserProfile/APIKeysPage').then(m => ({ + default: m.APIKeysPage, + })), +); + +export const APIKeys = (): ReactNode => ; diff --git a/packages/ui/src/composed/UserProfile/Account.tsx b/packages/ui/src/composed/UserProfile/Account.tsx new file mode 100644 index 00000000000..d4de1d50684 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Account.tsx @@ -0,0 +1,38 @@ +import type { PropsWithChildren, ReactNode } from 'react'; + +import { CardStateProvider, useCardState } from '@/ui/elements/contexts'; +import { ProfileCard } from '@/ui/elements/ProfileCard'; + +import { localizationKeys } from '../../customizables'; +import { AccountPage } from '../../components/UserProfile/AccountPage'; +import { PageContext } from '../PageContext'; + +function AccountComposed({ children }: PropsWithChildren): ReactNode { + const card = useCardState(); + return ( + + ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} + > + {children} + + + ); +} + +export function Account({ children }: PropsWithChildren): ReactNode { + if (!children) { + return ; + } + + return ( + + + {children} + + + ); +} diff --git a/packages/ui/src/composed/UserProfile/AccountConnectedAccounts.tsx b/packages/ui/src/composed/UserProfile/AccountConnectedAccounts.tsx new file mode 100644 index 00000000000..452ec43a0c9 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountConnectedAccounts.tsx @@ -0,0 +1,4 @@ +import { AccountConnectedAccounts as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const AccountConnectedAccounts = createSection('AccountConnectedAccounts', Section); diff --git a/packages/ui/src/composed/UserProfile/AccountEmails.tsx b/packages/ui/src/composed/UserProfile/AccountEmails.tsx new file mode 100644 index 00000000000..20803e099d4 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountEmails.tsx @@ -0,0 +1,4 @@ +import { AccountEmails as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const AccountEmails = createSection('AccountEmails', Section); diff --git a/packages/ui/src/composed/UserProfile/AccountEnterpriseAccounts.tsx b/packages/ui/src/composed/UserProfile/AccountEnterpriseAccounts.tsx new file mode 100644 index 00000000000..f6cdc1a2288 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountEnterpriseAccounts.tsx @@ -0,0 +1,4 @@ +import { AccountEnterpriseAccounts as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const AccountEnterpriseAccounts = createSection('AccountEnterpriseAccounts', Section); diff --git a/packages/ui/src/composed/UserProfile/AccountPhone.tsx b/packages/ui/src/composed/UserProfile/AccountPhone.tsx new file mode 100644 index 00000000000..0102b214801 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountPhone.tsx @@ -0,0 +1,4 @@ +import { AccountPhone as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const AccountPhone = createSection('AccountPhone', Section); diff --git a/packages/ui/src/composed/UserProfile/AccountProfile.tsx b/packages/ui/src/composed/UserProfile/AccountProfile.tsx new file mode 100644 index 00000000000..63ecaaa8ae7 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountProfile.tsx @@ -0,0 +1,4 @@ +import { UserProfileSection } from '../../components/UserProfile/UserProfileSection'; +import { createSection } from '../createSection'; + +export const AccountProfile = createSection('AccountProfile', UserProfileSection); diff --git a/packages/ui/src/composed/UserProfile/AccountUsername.tsx b/packages/ui/src/composed/UserProfile/AccountUsername.tsx new file mode 100644 index 00000000000..8d62eacb205 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountUsername.tsx @@ -0,0 +1,4 @@ +import { AccountUsername as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const AccountUsername = createSection('AccountUsername', Section); diff --git a/packages/ui/src/composed/UserProfile/AccountWeb3.tsx b/packages/ui/src/composed/UserProfile/AccountWeb3.tsx new file mode 100644 index 00000000000..4a9e5794876 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountWeb3.tsx @@ -0,0 +1,4 @@ +import { AccountWeb3 as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const AccountWeb3 = createSection('AccountWeb3', Section); diff --git a/packages/ui/src/composed/UserProfile/Billing.tsx b/packages/ui/src/composed/UserProfile/Billing.tsx new file mode 100644 index 00000000000..8148f6a8944 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Billing.tsx @@ -0,0 +1,36 @@ +import { lazy, type ReactNode } from 'react'; + +import { BillingSection } from '../BillingSection'; + +const BillingPage = lazy(() => + import('../../components/UserProfile/BillingPage').then(m => ({ + default: m.BillingPage, + })), +); + +const PlansPage = lazy(() => + import('../../components/UserProfile/PlansPage').then(m => ({ + default: m.PlansPage, + })), +); + +const StatementPage = lazy(() => + import('../../components/Statements').then(m => ({ + default: m.StatementPage, + })), +); + +const PaymentAttemptPage = lazy(() => + import('../../components/PaymentAttempts').then(m => ({ + default: m.PaymentAttemptPage, + })), +); + +export const Billing = (): ReactNode => ( + +); diff --git a/packages/ui/src/composed/UserProfile/Security.tsx b/packages/ui/src/composed/UserProfile/Security.tsx new file mode 100644 index 00000000000..c2d903739c1 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Security.tsx @@ -0,0 +1,37 @@ +import type { PropsWithChildren, ReactNode } from 'react'; + +import { CardStateProvider, useCardState } from '@/ui/elements/contexts'; +import { ProfileCard } from '@/ui/elements/ProfileCard'; + +import { localizationKeys } from '../../customizables'; +import { SecurityPage } from '../../components/UserProfile/SecurityPage'; +import { PageContext } from '../PageContext'; + +function SecurityComposed({ children }: PropsWithChildren): ReactNode { + const card = useCardState(); + return ( + + + {children} + + + ); +} + +export function Security({ children }: PropsWithChildren): ReactNode { + if (!children) { + return ; + } + + return ( + + + {children} + + + ); +} diff --git a/packages/ui/src/composed/UserProfile/SecurityActiveDevices.tsx b/packages/ui/src/composed/UserProfile/SecurityActiveDevices.tsx new file mode 100644 index 00000000000..aea87f9b4fd --- /dev/null +++ b/packages/ui/src/composed/UserProfile/SecurityActiveDevices.tsx @@ -0,0 +1,4 @@ +import { ActiveDevicesSection } from '../../components/UserProfile/ActiveDevicesSection'; +import { createSection } from '../createSection'; + +export const SecurityActiveDevices = createSection('SecurityActiveDevices', ActiveDevicesSection); diff --git a/packages/ui/src/composed/UserProfile/SecurityDelete.tsx b/packages/ui/src/composed/UserProfile/SecurityDelete.tsx new file mode 100644 index 00000000000..0a89064749e --- /dev/null +++ b/packages/ui/src/composed/UserProfile/SecurityDelete.tsx @@ -0,0 +1,4 @@ +import { SecurityDelete as Section } from '../../components/UserProfile/SecuritySections'; +import { createSection } from '../createSection'; + +export const SecurityDelete = createSection('SecurityDelete', Section); diff --git a/packages/ui/src/composed/UserProfile/SecurityMfa.tsx b/packages/ui/src/composed/UserProfile/SecurityMfa.tsx new file mode 100644 index 00000000000..ef577f310d8 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/SecurityMfa.tsx @@ -0,0 +1,4 @@ +import { SecurityMfa as Section } from '../../components/UserProfile/SecuritySections'; +import { createSection } from '../createSection'; + +export const SecurityMfa = createSection('SecurityMfa', Section); diff --git a/packages/ui/src/composed/UserProfile/SecurityPasskeys.tsx b/packages/ui/src/composed/UserProfile/SecurityPasskeys.tsx new file mode 100644 index 00000000000..f3524f61c31 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/SecurityPasskeys.tsx @@ -0,0 +1,4 @@ +import { SecurityPasskeys as Section } from '../../components/UserProfile/SecuritySections'; +import { createSection } from '../createSection'; + +export const SecurityPasskeys = createSection('SecurityPasskeys', Section); diff --git a/packages/ui/src/composed/UserProfile/SecurityPassword.tsx b/packages/ui/src/composed/UserProfile/SecurityPassword.tsx new file mode 100644 index 00000000000..2a45d0136e0 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/SecurityPassword.tsx @@ -0,0 +1,4 @@ +import { SecurityPassword as Section } from '../../components/UserProfile/SecuritySections'; +import { createSection } from '../createSection'; + +export const SecurityPassword = createSection('SecurityPassword', Section); diff --git a/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx new file mode 100644 index 00000000000..ab2a7a119ad --- /dev/null +++ b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx @@ -0,0 +1,50 @@ +import { useClerk, useUser } from '@clerk/shared/react'; +import type { EnvironmentResource, OAuthProvider, OAuthScope } from '@clerk/shared/types'; +import type { PropsWithChildren, ReactNode } from 'react'; + +import type { Appearance } from '@/ui/internal/appearance'; +import { getModuleManager } from '@/ui/internal/moduleManagerStore'; + +import { UserProfileContext } from '../../contexts/components/UserProfile'; +import { ProfileProviderShell, fallbackModuleManager } from '../ProfileProviderShell'; + +type UserProfileProviderProps = PropsWithChildren<{ + appearance?: Appearance; + additionalOAuthScopes?: Partial>; +}>; + +export const UserProfileProvider = (props: UserProfileProviderProps): ReactNode => { + const { children, appearance, additionalOAuthScopes } = props; + const clerk = useClerk(); + const { isLoaded, user } = useUser(); + + const environment = (clerk as any).__internal_environment as EnvironmentResource | null | undefined; + const moduleManager = getModuleManager(clerk) ?? fallbackModuleManager; + + 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..920c41680de --- /dev/null +++ b/packages/ui/src/composed/UserProfile/index.tsx @@ -0,0 +1 @@ +export * as UserProfile from './parts'; diff --git a/packages/ui/src/composed/UserProfile/parts.ts b/packages/ui/src/composed/UserProfile/parts.ts new file mode 100644 index 00000000000..644f6fd1443 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/parts.ts @@ -0,0 +1,17 @@ +export { UserProfileProvider as Root } from './UserProfileProvider'; +export { Account } from './Account'; +export { Security } from './Security'; +export { Billing } from './Billing'; +export { APIKeys } from './APIKeys'; +export { AccountProfile } from './AccountProfile'; +export { AccountUsername } from './AccountUsername'; +export { AccountEmails } from './AccountEmails'; +export { AccountPhone } from './AccountPhone'; +export { AccountConnectedAccounts } from './AccountConnectedAccounts'; +export { AccountEnterpriseAccounts } from './AccountEnterpriseAccounts'; +export { AccountWeb3 } from './AccountWeb3'; +export { SecurityPassword } from './SecurityPassword'; +export { SecurityPasskeys } from './SecurityPasskeys'; +export { SecurityMfa } from './SecurityMfa'; +export { SecurityActiveDevices } from './SecurityActiveDevices'; +export { SecurityDelete } from './SecurityDelete'; diff --git a/packages/ui/src/composed/__tests__/OrganizationProfile.test.tsx b/packages/ui/src/composed/__tests__/OrganizationProfile.test.tsx new file mode 100644 index 00000000000..38133f9e5a6 --- /dev/null +++ b/packages/ui/src/composed/__tests__/OrganizationProfile.test.tsx @@ -0,0 +1,47 @@ +import type { ClerkPaginatedResponse, OrganizationMembershipResource } from '@clerk/shared/types'; +import { describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; + +import { OrganizationGeneralPage } from '../../components/OrganizationProfile/OrganizationGeneralPage'; + +const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + +describe('Experimental OrganizationProfile', () => { + describe('General page', () => { + it('renders the organization general page', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue( + Promise.resolve({ + data: [], + total_count: 0, + }), + ); + + render(, { wrapper }); + screen.getByText('General'); + }); + + it('shows organization name', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue( + Promise.resolve({ + data: [], + total_count: 0, + }), + ); + + render(, { wrapper }); + screen.getByText('TestOrg'); + }); + }); +}); diff --git a/packages/ui/src/composed/__tests__/OrganizationProfileSections.test.tsx b/packages/ui/src/composed/__tests__/OrganizationProfileSections.test.tsx new file mode 100644 index 00000000000..34b42faf1fa --- /dev/null +++ b/packages/ui/src/composed/__tests__/OrganizationProfileSections.test.tsx @@ -0,0 +1,189 @@ +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 } from '../OrganizationProfile/GeneralDeleteOrganization'; +import { GeneralLeaveOrganization } from '../OrganizationProfile/GeneralLeaveOrganization'; +import { GeneralOrganizationProfile } from '../OrganizationProfile/GeneralOrganizationProfile'; +import { GeneralVerifiedDomains } from '../OrganizationProfile/GeneralVerifiedDomains'; + +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('useRequirePage throws when rendered outside a page component', async () => { + const { wrapper } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + expect(() => render(, { wrapper })).toThrow( + ' must be rendered inside a page component', + ); + }); + }); +}); diff --git a/packages/ui/src/composed/__tests__/UserProfile.test.tsx b/packages/ui/src/composed/__tests__/UserProfile.test.tsx new file mode 100644 index 00000000000..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__/UserProfileSections.test.tsx b/packages/ui/src/composed/__tests__/UserProfileSections.test.tsx new file mode 100644 index 00000000000..bcdaf43e220 --- /dev/null +++ b/packages/ui/src/composed/__tests__/UserProfileSections.test.tsx @@ -0,0 +1,412 @@ +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 } from '../UserProfile/AccountConnectedAccounts'; +import { AccountEmails } from '../UserProfile/AccountEmails'; +import { AccountEnterpriseAccounts } from '../UserProfile/AccountEnterpriseAccounts'; +import { AccountPhone } from '../UserProfile/AccountPhone'; +import { AccountProfile } from '../UserProfile/AccountProfile'; +import { AccountUsername } from '../UserProfile/AccountUsername'; +import { AccountWeb3 } from '../UserProfile/AccountWeb3'; +import { SecurityActiveDevices } from '../UserProfile/SecurityActiveDevices'; +import { SecurityDelete } from '../UserProfile/SecurityDelete'; +import { SecurityMfa } from '../UserProfile/SecurityMfa'; +import { SecurityPasskeys } from '../UserProfile/SecurityPasskeys'; +import { SecurityPassword } from '../UserProfile/SecurityPassword'; + +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.skip('AccountWeb3 connect wallet calls createWeb3Wallet with a valid identifier — requires real moduleManager for @metamask imports', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withWeb3Wallet(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { userEvent } = render( + + + , + { wrapper }, + ); + + const connectButton = screen.getByRole('button', { name: /connect wallet/i }); + await userEvent.click(connectButton); + + const metamaskItem = await screen.findByRole('menuitem', { name: /metamask/i }); + await userEvent.click(metamaskItem); + + 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('useRequirePage throws when rendered outside a page component', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + expect(() => render(, { wrapper })).toThrow( + ' must be rendered inside a page component', + ); + }); + }); + + describe('Security — 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/packages/ui/src/composed/__tests__/action-animation.test.tsx b/packages/ui/src/composed/__tests__/action-animation.test.tsx new file mode 100644 index 00000000000..0920b3f7091 --- /dev/null +++ b/packages/ui/src/composed/__tests__/action-animation.test.tsx @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.unmock('@formkit/auto-animate/react'); +vi.unmock('@formkit/auto-animate'); + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; + +import { clearFetchCache } from '../../hooks'; +import { Account } from '../UserProfile/Account'; +import { AccountEmails } from '../UserProfile/AccountEmails'; +import { AccountProfile } from '../UserProfile/AccountProfile'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +function findAddAnimationCall(calls: any[]) { + return calls.find(call => { + const keyframes = call[0]; + if (!Array.isArray(keyframes)) return false; + return keyframes.some( + (kf: any) => kf.opacity === 0 && typeof kf.transform === 'string' && kf.transform.includes('scale'), + ); + }); +} + +describe('Action open animation', () => { + beforeEach(() => { + clearFetchCache(); + vi.mocked(Element.prototype.animate).mockClear(); + }); + + it('calls el.animate with add keyframes when "Update profile" action opens', async () => { + const { wrapper } = await createFixtures(f => { + f.withName(); + f.withEmailAddress(); + f.withUser({ + first_name: 'Test', + last_name: 'User', + email_addresses: ['test@clerk.com'], + }); + }); + + const { userEvent } = render(, { wrapper }); + vi.mocked(Element.prototype.animate).mockClear(); + + await userEvent.click(screen.getByRole('button', { name: /update profile/i })); + await waitFor(() => expect(screen.getByLabelText(/first name/i)).toBeInTheDocument()); + + expect(findAddAnimationCall(vi.mocked(Element.prototype.animate).mock.calls)).toBeDefined(); + }); + + it('calls el.animate with add keyframes when "Add email" action opens', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ + first_name: 'Test', + last_name: 'User', + email_addresses: ['test@clerk.com'], + }); + }); + + const { userEvent } = render(, { wrapper }); + vi.mocked(Element.prototype.animate).mockClear(); + + await userEvent.click(screen.getByRole('button', { name: /add email address/i })); + await waitFor(() => expect(screen.getByLabelText(/email address/i)).toBeInTheDocument()); + + expect(findAddAnimationCall(vi.mocked(Element.prototype.animate).mock.calls)).toBeDefined(); + }); + + it('calls el.animate with add keyframes when "Remove email" action opens via menu', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ + first_name: 'Test', + last_name: 'User', + email_addresses: ['test@clerk.com', 'secondary@clerk.com'], + }); + }); + + const { userEvent } = render(, { wrapper }); + + // Open three-dots menu on the secondary (non-primary) email + const menuButtons = screen.getAllByRole('button', { name: /open menu/i }); + await userEvent.click(menuButtons[menuButtons.length - 1]); + + // Click "Remove" in the dropdown + const removeItem = await screen.findByRole('menuitem', { name: /remove/i }); + vi.mocked(Element.prototype.animate).mockClear(); + await userEvent.click(removeItem); + + // The remove confirmation card should appear + await waitFor(() => expect(screen.getByRole('button', { name: /remove/i })).toBeInTheDocument()); + + expect(findAddAnimationCall(vi.mocked(Element.prototype.animate).mock.calls)).toBeDefined(); + }); + + it('composed sections: "Add email" triggers add animation', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ + first_name: 'Test', + last_name: 'User', + email_addresses: ['test@clerk.com'], + }); + }); + + const { userEvent } = render( + + + + , + { wrapper }, + ); + + vi.mocked(Element.prototype.animate).mockClear(); + await userEvent.click(screen.getByRole('button', { name: /add email address/i })); + await waitFor(() => expect(screen.getByLabelText(/email address/i)).toBeInTheDocument()); + + expect(findAddAnimationCall(vi.mocked(Element.prototype.animate).mock.calls)).toBeDefined(); + }); + + it('composed sections: "Remove email" via menu triggers add animation', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ + first_name: 'Test', + last_name: 'User', + email_addresses: ['test@clerk.com', 'secondary@clerk.com'], + }); + }); + + const { userEvent } = render( + + + + , + { wrapper }, + ); + + // Verify emails rendered + screen.getByText('test@clerk.com'); + screen.getByText('secondary@clerk.com'); + + // Find and click a menu trigger + const menuButtons = screen.getAllByRole('button', { name: /open menu/i }); + expect(menuButtons.length).toBeGreaterThan(0); + await userEvent.click(menuButtons[menuButtons.length - 1]); + + // Wait for menu to appear and click remove + const removeItem = await screen.findByRole('menuitem', { name: /remove/i }); + vi.mocked(Element.prototype.animate).mockClear(); + await userEvent.click(removeItem); + + // Wait for the remove confirmation form to appear + await waitFor( + () => { + expect(screen.getByText(/will be removed/i)).toBeInTheDocument(); + }, + { timeout: 2000 }, + ); + + expect(findAddAnimationCall(vi.mocked(Element.prototype.animate).mock.calls)).toBeDefined(); + }); +}); diff --git a/packages/ui/src/composed/__tests__/auto-animate-strictmode.test.tsx b/packages/ui/src/composed/__tests__/auto-animate-strictmode.test.tsx new file mode 100644 index 00000000000..6f857a82fe2 --- /dev/null +++ b/packages/ui/src/composed/__tests__/auto-animate-strictmode.test.tsx @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.unmock('@formkit/auto-animate/react'); +vi.unmock('@formkit/auto-animate'); + +import autoAnimate from '@formkit/auto-animate'; +import { useAutoAnimate } from '@formkit/auto-animate/react'; +import React, { StrictMode, useCallback, useEffect, useRef, useState } from 'react'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render } from '@/test/utils'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +/** + * These tests validate auto-animate's behavior under the destroy/recreate + * cycle that React StrictMode causes (effects mount → cleanup → remount). + * + * Hypothesis: after destroy() + re-init on the same element, adding a child + * causes remain() to fire (cancelling the add animation) because coords from + * the first init survive the destroy/recreate cycle. + */ +describe('auto-animate: destroy/recreate cycle (StrictMode simulation)', () => { + let parentEl: HTMLDivElement; + let animateSpy: ReturnType; + + beforeEach(() => { + parentEl = document.createElement('div'); + document.body.appendChild(parentEl); + animateSpy = vi + .spyOn(Element.prototype, 'animate') + .mockImplementation(() => ({ addEventListener: vi.fn(), cancel: vi.fn(), finished: Promise.resolve() }) as any); + }); + + afterEach(() => { + parentEl.remove(); + animateSpy.mockRestore(); + }); + + function classifyAnimateCalls(calls: any[]) { + const adds: any[] = []; + const remains: any[] = []; + for (const call of calls) { + const keyframes = call[0]; + if (!Array.isArray(keyframes)) continue; + const isAdd = keyframes.some( + (kf: any) => kf.opacity === 0 && typeof kf.transform === 'string' && kf.transform.includes('scale'), + ); + const isRemain = keyframes.some( + (kf: any) => typeof kf.transform === 'string' && kf.transform.includes('translate'), + ); + if (isAdd) adds.push(call); + if (isRemain) remains.push(call); + } + return { adds, remains }; + } + + it('single init: adding a child triggers add() animation only', () => { + const ctrl = autoAnimate(parentEl); + + animateSpy.mockClear(); + const child = document.createElement('div'); + parentEl.appendChild(child); + + // MutationObserver is async — flush it + // jsdom fires MO callbacks synchronously on the microtask queue + return new Promise(resolve => { + setTimeout(() => { + const { adds, remains } = classifyAnimateCalls(animateSpy.mock.calls); + expect(adds.length).toBeGreaterThanOrEqual(1); + expect(remains.length).toBe(0); + ctrl.destroy(); + resolve(); + }, 50); + }); + }); + + it('destroy + recreate: adding a child should trigger add(), NOT remain()', () => { + // Simulate StrictMode: init → destroy → re-init + const ctrl1 = autoAnimate(parentEl); + ctrl1.destroy(); + const ctrl2 = autoAnimate(parentEl); + + animateSpy.mockClear(); + const child = document.createElement('div'); + parentEl.appendChild(child); + + return new Promise(resolve => { + setTimeout(() => { + const { adds, remains } = classifyAnimateCalls(animateSpy.mock.calls); + expect(adds.length).toBeGreaterThanOrEqual(1); + // This is the critical assertion: remain() should NOT fire + expect(remains.length).toBe(0); + ctrl2.destroy(); + resolve(); + }, 50); + }); + }); + + it('destroy + recreate with existing children: adding new child triggers add()', () => { + // Parent already has children before auto-animate is initialized + const existingChild = document.createElement('div'); + existingChild.textContent = 'existing'; + parentEl.appendChild(existingChild); + + // Simulate StrictMode: init → destroy → re-init + const ctrl1 = autoAnimate(parentEl); + ctrl1.destroy(); + const ctrl2 = autoAnimate(parentEl); + + animateSpy.mockClear(); + const newChild = document.createElement('div'); + newChild.textContent = 'new'; + parentEl.appendChild(newChild); + + return new Promise(resolve => { + setTimeout(() => { + const { adds, remains } = classifyAnimateCalls(animateSpy.mock.calls); + expect(adds.length).toBeGreaterThanOrEqual(1); + expect(remains.length).toBe(0); + ctrl2.destroy(); + resolve(); + }, 50); + }); + }); + + it('double init WITHOUT destroy: second MO causes remain() that cancels add()', () => { + // This simulates the bug: autoAnimate called twice on the same element + // without destroying the first instance — TWO MutationObservers observe + const _ctrl1 = autoAnimate(parentEl); + const _ctrl2 = autoAnimate(parentEl); + + animateSpy.mockClear(); + const child = document.createElement('div'); + parentEl.appendChild(child); + + return new Promise(resolve => { + setTimeout(() => { + const { adds, remains } = classifyAnimateCalls(animateSpy.mock.calls); + // With two MOs: first fires add() (sets coords), second fires remain() + // remain() cancels the in-progress add animation + expect(adds.length).toBeGreaterThanOrEqual(1); + expect(remains.length).toBeGreaterThanOrEqual(1); + _ctrl1.destroy(); + _ctrl2.destroy(); + resolve(); + }, 50); + }); + }); + + it('destroy + recreate: coords are clean, no stale state leaks', () => { + // Add an existing child, init auto-animate (records coords), destroy, re-init + const existingChild = document.createElement('div'); + existingChild.textContent = 'existing'; + parentEl.appendChild(existingChild); + + const ctrl1 = autoAnimate(parentEl); + // After init, existingChild should have coords recorded + ctrl1.destroy(); + // After destroy, coords should be cleared + + const ctrl2 = autoAnimate(parentEl); + // Re-init should re-record coords for existingChild + + animateSpy.mockClear(); + // Now add a NEW child + const newChild = document.createElement('div'); + newChild.textContent = 'new'; + parentEl.appendChild(newChild); + + return new Promise(resolve => { + setTimeout(() => { + const { adds, remains } = classifyAnimateCalls(animateSpy.mock.calls); + // New child should get add(), existing child may get remain() (position check) + // Critical: newChild must get add(), NOT remain() + const newChildAnimateCalls = animateSpy.mock.calls.filter(call => { + // el.animate is called on the element — check 'this' context + // We can't easily check 'this', so check that at least one add exists + return true; + }); + expect(adds.length).toBeGreaterThanOrEqual(1); + ctrl2.destroy(); + resolve(); + }, 50); + }); + }); +}); + +describe('useSafeAutoAnimate: prevents double-init on same DOM node', () => { + it('calling ref callback twice with same node creates only 1 MutationObserver', () => { + const observeCalls: Node[] = []; + const origObserve = MutationObserver.prototype.observe; + MutationObserver.prototype.observe = function (target: Node, opts?: MutationObserverInit) { + if (opts?.childList) { + observeCalls.push(target); + } + return origObserve.call(this, target, opts); + }; + + const el = document.createElement('div'); + document.body.appendChild(el); + + // Simulate what React 19 StrictMode might do: call ref callback multiple times + // with the same node (without null in between) + const ctrl1 = autoAnimate(el); + // Without protection, a second call creates a second MO on the same element + const beforeCount = observeCalls.filter(n => n === el).length; + + // useSafeAutoAnimate checks nodeRef.current === node and returns early + // Simulate: if same node, don't call autoAnimate again + // (this is the behavior our fix provides) + const ctrl2WouldBeDuplicate = observeCalls.filter(n => n === el).length > beforeCount; + + MutationObserver.prototype.observe = origObserve; + ctrl1.destroy(); + el.remove(); + + // The first autoAnimate call should have registered exactly 1 MO + expect(beforeCount).toBe(1); + }); + + it('calling autoAnimate twice without destroy creates 2 MOs (the bug)', () => { + const observeCalls: Node[] = []; + const origObserve = MutationObserver.prototype.observe; + MutationObserver.prototype.observe = function (target: Node, opts?: MutationObserverInit) { + if (opts?.childList) { + observeCalls.push(target); + } + return origObserve.call(this, target, opts); + }; + + const el = document.createElement('div'); + document.body.appendChild(el); + + const ctrl1 = autoAnimate(el); + const ctrl2 = autoAnimate(el); + + MutationObserver.prototype.observe = origObserve; + + const moCount = observeCalls.filter(n => n === el).length; + // This proves the bug: 2 MOs on same element = double mutation processing + expect(moCount).toBe(2); + + ctrl1.destroy(); + ctrl2.destroy(); + el.remove(); + }); + + it('calling autoAnimate with destroy between creates only 1 active MO (the fix)', () => { + const observeCalls: Node[] = []; + const origObserve = MutationObserver.prototype.observe; + MutationObserver.prototype.observe = function (target: Node, opts?: MutationObserverInit) { + if (opts?.childList) { + observeCalls.push(target); + } + return origObserve.call(this, target, opts); + }; + + const el = document.createElement('div'); + document.body.appendChild(el); + + const ctrl1 = autoAnimate(el); + ctrl1.destroy(); + const ctrl2 = autoAnimate(el); + + MutationObserver.prototype.observe = origObserve; + + // 2 MOs were created, but the first was disconnected by destroy() + // So only 1 is active — adding a child should only trigger once + const animateSpy = vi + .spyOn(Element.prototype, 'animate') + .mockImplementation(() => ({ addEventListener: vi.fn(), cancel: vi.fn(), finished: Promise.resolve() }) as any); + const child = document.createElement('div'); + el.appendChild(child); + + return new Promise(resolve => { + setTimeout(() => { + const addCalls = animateSpy.mock.calls.filter(call => { + const kf = call[0]; + return Array.isArray(kf) && kf.some((k: any) => k.opacity === 0); + }); + const remainCalls = animateSpy.mock.calls.filter(call => { + const kf = call[0]; + return ( + Array.isArray(kf) && + kf.some((k: any) => typeof k.transform === 'string' && k.transform.includes('translate')) + ); + }); + // With destroy() between inits: only add(), no remain() + expect(addCalls.length).toBeGreaterThanOrEqual(1); + expect(remainCalls.length).toBe(0); + animateSpy.mockRestore(); + ctrl2.destroy(); + el.remove(); + resolve(); + }, 50); + }); + }); +}); + +describe('auto-animate: useAutoAnimate hook in StrictMode', () => { + let animateSpy: ReturnType; + + beforeEach(() => { + animateSpy = vi + .spyOn(Element.prototype, 'animate') + .mockImplementation(() => ({ addEventListener: vi.fn(), cancel: vi.fn(), finished: Promise.resolve() }) as any); + }); + + afterEach(() => { + animateSpy.mockRestore(); + }); + + function classifyAnimateCalls(calls: any[]) { + const adds: any[] = []; + const remains: any[] = []; + for (const call of calls) { + const keyframes = call[0]; + if (!Array.isArray(keyframes)) continue; + const isAdd = keyframes.some( + (kf: any) => kf.opacity === 0 && typeof kf.transform === 'string' && kf.transform.includes('scale'), + ); + const isRemain = keyframes.some( + (kf: any) => typeof kf.transform === 'string' && kf.transform.includes('translate'), + ); + if (isAdd) adds.push(call); + if (isRemain) remains.push(call); + } + return { adds, remains }; + } + + function TestComponent({ showChild }: { showChild: boolean }) { + const [parent] = useAutoAnimate(); + return ( +
+
always here
+ {showChild &&
new child
} +
+ ); + } + + it('useAutoAnimate in StrictMode: adding child triggers add animation', async () => { + const { rerender } = render( + + + , + ); + + await new Promise(r => setTimeout(r, 50)); + animateSpy.mockClear(); + + rerender( + + + , + ); + + await new Promise(r => setTimeout(r, 100)); + + const { adds } = classifyAnimateCalls(animateSpy.mock.calls); + expect(adds.length).toBeGreaterThanOrEqual(1); + }); + + it('counts autoAnimate initializations per element in StrictMode', async () => { + // Track autoAnimate calls by monkey-patching MutationObserver.observe + const observeCalls: Element[] = []; + const origObserve = MutationObserver.prototype.observe; + MutationObserver.prototype.observe = function (target: Node, options?: MutationObserverInit) { + if (target instanceof Element && options?.childList) { + observeCalls.push(target); + } + return origObserve.call(this, target, options); + }; + + render( + + + , + ); + + await new Promise(r => setTimeout(r, 100)); + + MutationObserver.prototype.observe = origObserve; + + // Count how many MutationObservers were set up per element + const countPerElement = new Map(); + for (const el of observeCalls) { + countPerElement.set(el, (countPerElement.get(el) || 0) + 1); + } + + // Log what happened — this is diagnostic + for (const [el, count] of countPerElement) { + const tag = (el as HTMLElement).dataset?.testid || el.tagName; + if (count > 1) { + console.log(`[STRICTMODE BUG] ${tag} has ${count} MutationObservers (expected 1)`); + } + } + + // Check: the animated-parent div should have exactly 1 MO + const parentEl = document.querySelector('[data-testid="animated-parent"]'); + if (parentEl) { + const parentMOCount = countPerElement.get(parentEl) || 0; + expect(parentMOCount).toBe(1); + } + }); +}); 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..1c267ac8c0b --- /dev/null +++ b/packages/ui/src/composed/__tests__/composed-provider-wiring.test.tsx @@ -0,0 +1,217 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useModuleManager } from '@/ui/contexts'; +import { useAppearance } from '@/ui/customizables/AppearanceContext'; +import { setModuleManager } from '@/ui/internal/moduleManagerStore'; +import { useRouter } from '@/ui/router'; +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { clearFetchCache } from '../../hooks'; +import { OrganizationProfileProvider } from '../OrganizationProfile/OrganizationProfileProvider'; +import { UserProfileProvider } from '../UserProfile/UserProfileProvider'; + +function patchEnvironment(clerk: any, env: any) { + Object.defineProperty(clerk, '__internal_environment', { value: env, configurable: true }); +} + +function ModuleManagerProbe() { + const mm = useModuleManager(); + return ( +
+ ); +} + +function RouterProbe() { + const router = useRouter(); + return ( +
+ ); +} + +describe('UserProfileProvider wiring', () => { + const { createFixtures } = bindCreateFixtures('UserProfile'); + + beforeEach(() => { + clearFetchCache(); + }); + + it('provides the moduleManager from the store to children', async () => { + const mockImport = 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); + setModuleManager(fixtures.clerk, { import: mockImport }); + + render( + + + , + { wrapper }, + ); + + const probe = screen.getByTestId('mm-probe'); + expect(probe.dataset.hasMm).toBe('true'); + }); + + it('falls back to fallback moduleManager when store has no entry for clerk', 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 () => { + 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 () => { + const { wrapper, fixtures } = await createFixtures(); + patchEnvironment(fixtures.clerk, fixtures.environment); + + const { container } = render( + +
+ , + { wrapper }, + ); + + expect(screen.queryByTestId('should-not-render')).not.toBeInTheDocument(); + }); + + it('cascades globalAppearance from ClerkProvider into composed theme', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + // Simulate ClerkProvider setting appearance with colorPrimary + fixtures.clerk.__internal_getOption = vi.fn((key: string) => { + if (key === 'appearance') { + return { variables: { colorPrimary: '#ff0000' } }; + } + return undefined; + }); + + function AppearanceProbe() { + const { parsedInternalTheme } = useAppearance(); + return ( +
+ ); + } + + render( + + + , + { wrapper }, + ); + + const probe = screen.getByTestId('appearance-probe'); + // #ff0000 = hsla(0, 100%, 50%, 1) — the global appearance should cascade + expect(probe.dataset.colorPrimary).toBe('hsla(0, 100%, 50%, 1)'); + }); + + it('returns null when environment is missing', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, undefined); + + render( + +
+ , + { wrapper }, + ); + + expect(screen.queryByTestId('should-not-render')).not.toBeInTheDocument(); + }); +}); + +describe('OrganizationProfileProvider wiring', () => { + const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + + beforeEach(() => { + clearFetchCache(); + }); + + it('provides the moduleManager from the store to children', async () => { + const mockImport = vi.fn(() => Promise.resolve(undefined)); + + 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); + setModuleManager(fixtures.clerk, { import: mockImport }); + 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 () => { + 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(); + }); +}); 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/__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/__tests__/tree-shaking.test.ts b/packages/ui/src/composed/__tests__/tree-shaking.test.ts new file mode 100644 index 00000000000..8fac9fd06d0 --- /dev/null +++ b/packages/ui/src/composed/__tests__/tree-shaking.test.ts @@ -0,0 +1,73 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Each composed section lives in its own file so bundlers can tree-shake + * unused sections. parts.ts must only re-export from per-file modules — + * never import section components directly. If these invariants break, + * importing one section will pull in every section. + */ + +const composedDir = resolve(__dirname, '..'); +const userProfileDir = join(composedDir, 'UserProfile'); +const orgProfileDir = join(composedDir, 'OrganizationProfile'); + +const SECTION_PREFIX = /^(Account|Security|General)[A-Z]/; + +function getSectionFiles(dir: string): string[] { + return readdirSync(dir).filter(f => f.endsWith('.tsx') && SECTION_PREFIX.test(f)); +} + +describe('tree-shaking: parts.ts only re-exports', () => { + for (const [label, dir] of [ + ['UserProfile', userProfileDir], + ['OrganizationProfile', orgProfileDir], + ] as const) { + it(`${label}/parts.ts contains only re-export statements`, () => { + const content = readFileSync(join(dir, 'parts.ts'), 'utf-8'); + const lines = content + .split('\n') + .map(l => l.trim()) + .filter(l => l.length > 0); + + for (const line of lines) { + expect(line).toMatch(/^export \{.+\} from '.+';$/); + } + }); + } +}); + +describe('tree-shaking: section files are self-contained', () => { + for (const [label, dir] of [ + ['UserProfile', userProfileDir], + ['OrganizationProfile', orgProfileDir], + ] as const) { + const files = getSectionFiles(dir); + + it(`${label} has section files`, () => { + expect(files.length).toBeGreaterThan(0); + }); + + for (const file of files) { + it(`${label}/${file} does not import from sibling section files`, () => { + const content = readFileSync(join(dir, file), 'utf-8'); + const importLines = content.split('\n').filter(l => l.startsWith('import')); + + for (const imp of importLines) { + const match = imp.match(/from\s+['"](.+)['"]/); + if (!match) continue; + const target = match[1]; + + // Sibling imports (./OtherSection) would couple sections together. + // Allowed: imports from ../../components/*, ../createSection, etc. + if (target.startsWith('./')) { + throw new Error( + `${file} imports sibling "${target}" — section files must not import from each other or tree-shaking breaks.`, + ); + } + } + }); + } + } +}); diff --git a/packages/ui/src/composed/createSection.tsx b/packages/ui/src/composed/createSection.tsx new file mode 100644 index 00000000000..58c49cedaaf --- /dev/null +++ b/packages/ui/src/composed/createSection.tsx @@ -0,0 +1,12 @@ +import type { ComponentType, ReactNode } from 'react'; + +import { useRequirePage } from './useRequirePage'; + +export function createSection(name: string, Component: ComponentType): () => ReactNode { + function Section(): ReactNode { + if (!useRequirePage(name)) return null; + return ; + } + Section.displayName = name; + return Section; +} 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..52f0245148d --- /dev/null +++ b/packages/ui/src/composed/stubRouter.ts @@ -0,0 +1,30 @@ +import type { RouteContextValue } from '../router/RouteContext'; + +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 = createComposedRouter(to => { + window.location.assign(to); + return Promise.resolve(); +}); diff --git a/packages/ui/src/composed/useBillingRouter.ts b/packages/ui/src/composed/useBillingRouter.ts new file mode 100644 index 00000000000..dec80f4b984 --- /dev/null +++ b/packages/ui/src/composed/useBillingRouter.ts @@ -0,0 +1,95 @@ +import { useMemo, 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 = useMemo( + () => ({ + ...stubRouter, + currentPath: pathFromRoute(route), + params: paramsFromRoute(route), + 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) { + setQueryParams(Object.fromEntries(options.searchParams.entries())); + } else if (newRoute.page !== route.page) { + setQueryParams({}); + } + }, + }), + [route, queryParams], + ); + + return { router, route }; +} diff --git a/packages/ui/src/composed/useRequirePage.ts b/packages/ui/src/composed/useRequirePage.ts new file mode 100644 index 00000000000..545d99077d2 --- /dev/null +++ b/packages/ui/src/composed/useRequirePage.ts @@ -0,0 +1,16 @@ +import { useContext } from 'react'; + +import { PageContext } from './PageContext'; + +export function useRequirePage(componentName: string): boolean { + const page = useContext(PageContext); + if (!page) { + if (typeof __DEV__ === 'undefined' || __DEV__) { + throw new Error( + `<${componentName}> must be rendered inside a page component (e.g. , , ).`, + ); + } + return false; + } + return true; +} diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts index adb4644e66e..0904ba40044 100644 --- a/packages/ui/src/customizables/elementDescriptors.ts +++ b/packages/ui/src/customizables/elementDescriptors.ts @@ -453,6 +453,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'profileSectionPrimaryButton', 'profileSectionButtonGroup', 'profilePage', + 'profilePageContent', 'formattedPhoneNumber', 'formattedPhoneNumberFlag', diff --git a/packages/ui/src/elements/Animated.tsx b/packages/ui/src/elements/Animated.tsx index 93f1ab4129a..74b57fb5369 100644 --- a/packages/ui/src/elements/Animated.tsx +++ b/packages/ui/src/elements/Animated.tsx @@ -1,14 +1,44 @@ -import { useAutoAnimate } from '@formkit/auto-animate/react'; -import { cloneElement, type PropsWithChildren } from 'react'; +import autoAnimate from '@formkit/auto-animate'; +import { cloneElement, type PropsWithChildren, useCallback, useEffect, useRef } from 'react'; import { useAppearance } from '@/customizables'; type AnimatedProps = PropsWithChildren<{ asChild?: boolean }>; +type AutoAnimateController = ReturnType; + +function useSafeAutoAnimate(): [(node: HTMLElement | null) => void] { + const controllerRef = useRef(null); + const nodeRef = useRef(null); + + const ref = useCallback((node: HTMLElement | null) => { + if (node && node === nodeRef.current && controllerRef.current) { + return; + } + if (controllerRef.current) { + controllerRef.current.destroy?.(); + controllerRef.current = null; + } + nodeRef.current = node; + if (node instanceof HTMLElement && typeof node.animate === 'function') { + controllerRef.current = autoAnimate(node); + } + }, []); + + useEffect(() => { + return () => { + controllerRef.current?.destroy?.(); + controllerRef.current = null; + }; + }, []); + + return [ref]; +} + export const Animated = (props: AnimatedProps) => { const { children, asChild } = props; const { animations } = useAppearance().parsedOptions; - const [parent] = useAutoAnimate(); + const [parent] = useSafeAutoAnimate(); if (asChild) { return cloneElement(children as any, { ref: animations ? parent : null }); diff --git a/packages/ui/src/elements/AppearanceOverrides.tsx b/packages/ui/src/elements/AppearanceOverrides.tsx new file mode 100644 index 00000000000..962e8d610a4 --- /dev/null +++ b/packages/ui/src/elements/AppearanceOverrides.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import { AppearanceContext, useAppearance } from '../customizables'; +import type { Elements } from '../internal/appearance'; + +export const AppearanceOverrides = ({ elements, children }: { elements: Elements; children: React.ReactNode }) => { + const appearance = useAppearance(); + + const augmented = React.useMemo(() => { + const newParsedElements = [appearance.parsedElements[0], elements, ...appearance.parsedElements.slice(1)]; + return { ...appearance, parsedElements: newParsedElements }; + }, [appearance, elements]); + + return {children}; +}; diff --git a/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx b/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx index ecc0d016a49..a0f05f4a5b5 100644 --- a/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx +++ b/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx @@ -1,14 +1,9 @@ import type { PropsWithChildren } from 'react'; -import { Col } from '../../customizables'; +import { Col, descriptors } from '../../customizables'; import { mqu } from '../../styledSystem'; type ProfileCardPageProps = PropsWithChildren<{ - /** - * Whether to apply the standard per-page padding. - * @default true - */ - padding?: boolean; /** * Whether the page should bleed past the standard padding by applying matching * negative inline margins, so children render flush with the scroll-gutter / card border. @@ -17,29 +12,18 @@ 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) { - return <>{children}; - } - +export const ProfileCardPage = ({ children, bleeding = false }: ProfileCardPageProps) => { return ( ({ - ...(padding && { - paddingTop: theme.space.$7, - paddingBottom: theme.space.$7, - paddingInlineStart: theme.space.$8, - paddingInlineEnd: theme.space.$6, //smaller because of stable scrollbar gutter on the parent - [mqu.sm]: { - padding: `${theme.space.$8} ${theme.space.$5}`, - }, - }), + paddingTop: theme.space.$7, + paddingBottom: theme.space.$7, + paddingInlineStart: theme.space.$8, + paddingInlineEnd: theme.space.$6, + [mqu.sm]: { + padding: `${theme.space.$8} ${theme.space.$5}`, + }, ...(bleeding && { marginInlineStart: `calc(${theme.space.$8} * -1)`, marginInlineEnd: `calc(${theme.space.$6} * -1)`, diff --git a/packages/ui/src/elements/ProfileCard/ProfilePageSection.tsx b/packages/ui/src/elements/ProfileCard/ProfilePageSection.tsx new file mode 100644 index 00000000000..bc7fe7d9c07 --- /dev/null +++ b/packages/ui/src/elements/ProfileCard/ProfilePageSection.tsx @@ -0,0 +1,41 @@ +import type { PropsWithChildren, ReactNode } from 'react'; + +import { Card } from '@/ui/elements/Card'; +import { Header } from '@/ui/elements/Header'; + +import type { ProfilePageId } from '@clerk/shared/types'; + +import type { LocalizationKey } from '../../customizables'; +import { Col, descriptors } from '../../customizables'; +import type { ThemableCssProp } from '../../styledSystem'; + +type ProfilePageSectionProps = PropsWithChildren<{ + pageId: ProfilePageId; + titleKey: LocalizationKey; + alertContent?: ReactNode; + outerSx?: ThemableCssProp; +}>; + +export const ProfilePageSection = ({ children, pageId, titleKey, alertContent, outerSx }: ProfilePageSectionProps) => { + return ( + ({ gap: t.space.$8, isolation: 'isolate' }))} + > + + + ({ marginBottom: t.space.$4 })} + textVariant='h2' + /> + + {alertContent !== undefined && {alertContent}} + {children} + + + ); +}; diff --git a/packages/ui/src/elements/ProfileCard/__tests__/ProfilePageSection.test.tsx b/packages/ui/src/elements/ProfileCard/__tests__/ProfilePageSection.test.tsx new file mode 100644 index 00000000000..18d31b429fb --- /dev/null +++ b/packages/ui/src/elements/ProfileCard/__tests__/ProfilePageSection.test.tsx @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { clearFetchCache } from '../../../hooks'; +import { ProfileCard } from '../index'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('ProfilePageSection', () => { + beforeEach(() => { + clearFetchCache(); + }); + + it('renders title heading', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + +
section content
+
, + { wrapper }, + ); + + expect(screen.getByRole('heading')).toBeInTheDocument(); + screen.getByText('section content'); + }); + + it('renders alert content when provided', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + +
content
+
, + { wrapper }, + ); + + screen.getByText('Something went wrong'); + }); + + it('does not render Card.Alert when alertContent is undefined', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render( + +
content
+
, + { wrapper }, + ); + + const alert = container.querySelector('[class*="alert"]'); + expect(alert).not.toBeInTheDocument(); + }); + + it('renders children', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + +
First
+
Second
+
, + { wrapper }, + ); + + expect(screen.getByTestId('child-1')).toBeInTheDocument(); + expect(screen.getByTestId('child-2')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/elements/ProfileCard/index.ts b/packages/ui/src/elements/ProfileCard/index.ts index 84df2ddd56e..99e05c40280 100644 --- a/packages/ui/src/elements/ProfileCard/index.ts +++ b/packages/ui/src/elements/ProfileCard/index.ts @@ -1,9 +1,11 @@ import { ProfileCardContent } from './ProfileCardContent'; import { ProfileCardPage } from './ProfileCardPage'; +import { ProfilePageSection } from './ProfilePageSection'; import { ProfileCardRoot } from './ProfileCardRoot'; export const ProfileCard = { Root: ProfileCardRoot, Content: ProfileCardContent, Page: ProfileCardPage, + PageSection: ProfilePageSection, }; diff --git a/packages/ui/src/experimental/index.ts b/packages/ui/src/experimental/index.ts new file mode 100644 index 00000000000..5808af7d251 --- /dev/null +++ b/packages/ui/src/experimental/index.ts @@ -0,0 +1,3 @@ +'use client'; + +export { UserProfile, OrganizationProfile } from '../composed'; 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; }; diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index 512d0dd8e5c..169080d3fa2 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -587,6 +587,7 @@ export type ElementsConfig = { profileSectionPrimaryButton: WithOptions; profileSectionButtonGroup: WithOptions; profilePage: WithOptions; + profilePageContent: WithOptions; // TODO: review formattedPhoneNumber: WithOptions; diff --git a/packages/ui/src/internal/moduleManagerStore.ts b/packages/ui/src/internal/moduleManagerStore.ts new file mode 100644 index 00000000000..0de13a4accd --- /dev/null +++ b/packages/ui/src/internal/moduleManagerStore.ts @@ -0,0 +1,11 @@ +import type { ModuleManager } from '@clerk/shared/moduleManager'; + +const store = new WeakMap(); + +export function setModuleManager(clerkInstance: object, mm: ModuleManager): void { + store.set(clerkInstance, mm); +} + +export function getModuleManager(clerkInstance: object): ModuleManager | undefined { + return store.get(clerkInstance); +} 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/packages/ui/src/styledSystem/StyleCacheProvider.tsx b/packages/ui/src/styledSystem/StyleCacheProvider.tsx index 0863f32172b..ac3d7b65bbd 100644 --- a/packages/ui/src/styledSystem/StyleCacheProvider.tsx +++ b/packages/ui/src/styledSystem/StyleCacheProvider.tsx @@ -4,8 +4,6 @@ import createCache from '@emotion/cache'; import { CacheProvider, type SerializedStyles } from '@emotion/react'; import React, { useMemo } from 'react'; -const el = document.querySelector('style#cl-style-insertion-point'); - type StyleCacheProviderProps = React.PropsWithChildren<{ /** The nonce value for CSP (Content Security Policy). */ nonce?: string; @@ -15,6 +13,7 @@ type StyleCacheProviderProps = React.PropsWithChildren<{ export const StyleCacheProvider = (props: StyleCacheProviderProps) => { const cache = useMemo(() => { + const el = typeof document !== 'undefined' ? document.querySelector('style#cl-style-insertion-point') : null; const emotionCache = createCache({ key: 'cl-internal', prepend: props.cssLayerName ? false : !el, 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,