From 8ffd832ee0ef2633141bfc4f4232bdcb22abf0f2 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 10 Jul 2026 16:52:51 -0400 Subject: [PATCH] feat(ui): add composed OrganizationProfile panels and section components --- .changeset/profile-composed-orgprofile.md | 2 + .../composed/OrganizationProfile/APIKeys.tsx | 13 + .../composed/OrganizationProfile/Billing.tsx | 38 ++ .../composed/OrganizationProfile/General.tsx | 41 ++ .../GeneralDeleteOrganization.tsx | 9 + .../GeneralLeaveOrganization.tsx | 9 + .../GeneralOrganizationProfile.tsx | 9 + .../GeneralVerifiedDomains.tsx | 18 + .../composed/OrganizationProfile/Members.tsx | 7 + .../OrganizationProfileProvider.tsx | 57 ++ .../composed/OrganizationProfile/Security.tsx | 22 + .../composed/OrganizationProfile/index.tsx | 10 + .../__tests__/OrganizationProfile.test.tsx | 47 ++ .../OrganizationProfileSections.test.tsx | 150 +++++ .../composed-provider-wiring.test.tsx | 569 ++++++++++++++++++ .../__tests__/style-cache-sharing.test.tsx | 150 +++++ 16 files changed, 1151 insertions(+) create mode 100644 .changeset/profile-composed-orgprofile.md create mode 100644 packages/ui/src/composed/OrganizationProfile/APIKeys.tsx create mode 100644 packages/ui/src/composed/OrganizationProfile/Billing.tsx create mode 100644 packages/ui/src/composed/OrganizationProfile/General.tsx create mode 100644 packages/ui/src/composed/OrganizationProfile/GeneralDeleteOrganization.tsx create mode 100644 packages/ui/src/composed/OrganizationProfile/GeneralLeaveOrganization.tsx create mode 100644 packages/ui/src/composed/OrganizationProfile/GeneralOrganizationProfile.tsx create mode 100644 packages/ui/src/composed/OrganizationProfile/GeneralVerifiedDomains.tsx create mode 100644 packages/ui/src/composed/OrganizationProfile/Members.tsx create mode 100644 packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx create mode 100644 packages/ui/src/composed/OrganizationProfile/Security.tsx create mode 100644 packages/ui/src/composed/OrganizationProfile/index.tsx create mode 100644 packages/ui/src/composed/__tests__/OrganizationProfile.test.tsx create mode 100644 packages/ui/src/composed/__tests__/OrganizationProfileSections.test.tsx create mode 100644 packages/ui/src/composed/__tests__/composed-provider-wiring.test.tsx create mode 100644 packages/ui/src/composed/__tests__/style-cache-sharing.test.tsx diff --git a/.changeset/profile-composed-orgprofile.md b/.changeset/profile-composed-orgprofile.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/profile-composed-orgprofile.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/composed/OrganizationProfile/APIKeys.tsx b/packages/ui/src/composed/OrganizationProfile/APIKeys.tsx new file mode 100644 index 00000000000..2dd72af55cc --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/APIKeys.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { lazy, type ReactNode } from 'react'; + +import { APIKeysSection } from '../APIKeysSection'; + +const OrganizationAPIKeysPage = lazy(() => + import('../../components/OrganizationProfile/OrganizationAPIKeysPage').then(m => ({ + default: m.OrganizationAPIKeysPage, + })), +); + +export const OrganizationProfileAPIKeysPanel = (): ReactNode => ; diff --git a/packages/ui/src/composed/OrganizationProfile/Billing.tsx b/packages/ui/src/composed/OrganizationProfile/Billing.tsx new file mode 100644 index 00000000000..aac613207ae --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/Billing.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { lazy, type ReactNode } from 'react'; + +import { BillingSection } from '../BillingSection'; + +const OrganizationBillingPage = lazy(() => + import('../../components/OrganizationProfile/OrganizationBillingPage').then(m => ({ + default: m.OrganizationBillingPage, + })), +); + +const OrganizationPlansPage = lazy(() => + import('../../components/OrganizationProfile/OrganizationPlansPage').then(m => ({ + default: m.OrganizationPlansPage, + })), +); + +const OrganizationStatementPage = lazy(() => + import('../../components/OrganizationProfile/OrganizationStatementPage').then(m => ({ + default: m.OrganizationStatementPage, + })), +); + +const OrganizationPaymentAttemptPage = lazy(() => + import('../../components/OrganizationProfile/OrganizationPaymentAttemptPage').then(m => ({ + default: m.OrganizationPaymentAttemptPage, + })), +); + +export const OrganizationProfileBillingPanel = (): ReactNode => ( + +); diff --git a/packages/ui/src/composed/OrganizationProfile/General.tsx b/packages/ui/src/composed/OrganizationProfile/General.tsx new file mode 100644 index 00000000000..fdf8bbeacdf --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/General.tsx @@ -0,0 +1,41 @@ +'use client'; + +import type { PropsWithChildren, ReactNode } from 'react'; + +import { CardStateProvider, useCardState } from '@/ui/elements/contexts'; +import { ProfileCard } from '@/ui/elements/ProfileCard'; + +import { OrganizationGeneralPage } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { localizationKeys } from '../../customizables'; +import { PageContext } from '../PageContext'; + +function GeneralComposed({ children }: PropsWithChildren): ReactNode { + const card = useCardState(); + return ( + + + {children} + + + ); +} + +export function OrganizationProfileGeneralPanel({ children }: PropsWithChildren): ReactNode { + if (!children) { + return ; + } + + // The section confirmation forms (leave/delete) call useCardState(), so children must be wrapped + // in a CardStateProvider — mirroring the UserProfile Account/Security composed panels. + return ( + + + {children} + + + ); +} diff --git a/packages/ui/src/composed/OrganizationProfile/GeneralDeleteOrganization.tsx b/packages/ui/src/composed/OrganizationProfile/GeneralDeleteOrganization.tsx new file mode 100644 index 00000000000..5372a3efbaf --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/GeneralDeleteOrganization.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { OrganizationDeleteSection } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { createSection } from '../createSection'; + +export const OrganizationProfileDeleteSection = createSection( + 'OrganizationProfileDeleteSection', + OrganizationDeleteSection, +); diff --git a/packages/ui/src/composed/OrganizationProfile/GeneralLeaveOrganization.tsx b/packages/ui/src/composed/OrganizationProfile/GeneralLeaveOrganization.tsx new file mode 100644 index 00000000000..d48f2053227 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/GeneralLeaveOrganization.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { OrganizationLeaveSection } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { createSection } from '../createSection'; + +export const OrganizationProfileLeaveSection = createSection( + 'OrganizationProfileLeaveSection', + OrganizationLeaveSection, +); diff --git a/packages/ui/src/composed/OrganizationProfile/GeneralOrganizationProfile.tsx b/packages/ui/src/composed/OrganizationProfile/GeneralOrganizationProfile.tsx new file mode 100644 index 00000000000..b58b137287d --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/GeneralOrganizationProfile.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { OrganizationProfileSection } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { createSection } from '../createSection'; + +export const OrganizationProfileProfileSection = createSection( + 'OrganizationProfileProfileSection', + OrganizationProfileSection, +); diff --git a/packages/ui/src/composed/OrganizationProfile/GeneralVerifiedDomains.tsx b/packages/ui/src/composed/OrganizationProfile/GeneralVerifiedDomains.tsx new file mode 100644 index 00000000000..06f0984b0c4 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/GeneralVerifiedDomains.tsx @@ -0,0 +1,18 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { Protect } from '../../common'; +import { OrganizationDomainsSection } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { useRequirePage } from '../useRequirePage'; + +export function OrganizationProfileDomainsSection(): ReactNode { + if (!useRequirePage('OrganizationProfileDomainsSection')) { + return null; + } + return ( + + + + ); +} diff --git a/packages/ui/src/composed/OrganizationProfile/Members.tsx b/packages/ui/src/composed/OrganizationProfile/Members.tsx new file mode 100644 index 00000000000..8fc817c76fd --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/Members.tsx @@ -0,0 +1,7 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { OrganizationMembers } from '../../components/OrganizationProfile/OrganizationMembers'; + +export const OrganizationProfileMembersPanel = (): ReactNode => ; diff --git a/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx b/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx new file mode 100644 index 00000000000..1cfdd5d17ff --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { useClerk, useOrganization, useUser } from '@clerk/shared/react'; +import type { EnvironmentResource, OrganizationProfileProps } from '@clerk/shared/types'; +import type { PropsWithChildren, ReactNode } from 'react'; + +import type { Appearance } from '@/ui/internal/appearance'; + +import { OrganizationProfileContext } from '../../contexts/components/OrganizationProfile'; +import { SubscriberTypeContext } from '../../contexts/components/SubscriberType'; +import { fallbackModuleManager, ProfileProviderShell } from '../ProfileProviderShell'; + +type OrganizationProfileProviderProps = PropsWithChildren<{ + appearance?: Appearance; + afterLeaveOrganizationUrl?: OrganizationProfileProps['afterLeaveOrganizationUrl']; + apiKeysProps?: OrganizationProfileProps['apiKeysProps']; +}>; + +export const OrganizationProfileProvider = (props: OrganizationProfileProviderProps): ReactNode => { + const { children, appearance, afterLeaveOrganizationUrl, apiKeysProps } = props; + const clerk = useClerk(); + const { isLoaded, user } = useUser(); + const { organization } = useOrganization(); + + const environment = (clerk as any).__internal_environment as EnvironmentResource | null | undefined; + const moduleManager = clerk.__internal_moduleManager ?? fallbackModuleManager; + + if (!isLoaded || !user || !organization || !environment) { + return null; + } + + const orgProfileCtxValue = { + componentName: 'OrganizationProfile' as const, + mode: 'mounted' as const, + routing: 'hash' as const, + path: undefined, + afterLeaveOrganizationUrl, + apiKeysProps, + customPages: [], + }; + + return ( + + + {children} + + + ); +}; diff --git a/packages/ui/src/composed/OrganizationProfile/Security.tsx b/packages/ui/src/composed/OrganizationProfile/Security.tsx new file mode 100644 index 00000000000..76020ef963b --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/Security.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { type ReactNode, useRef } from 'react'; + +import { CardStateProvider } from '@/ui/elements/contexts'; + +import { OrganizationSecurityPage } from '../../components/OrganizationProfile/OrganizationSecurityPage'; + +// The security tab has no composable sub-sections — it is an SSO overview plus a configuration +// wizard driven by internal view state. So the composed panel renders the whole standard security +// page (`OrganizationSecurityPage`), mirroring what `` shows on its security +// route. The confirmation/wizard forms call useCardState(), so children must sit under a +// CardStateProvider, matching how the standard component wraps the page. +export const OrganizationProfileSecurityPanel = (): ReactNode => { + const contentRef = useRef(null); + + return ( + + + + ); +}; diff --git a/packages/ui/src/composed/OrganizationProfile/index.tsx b/packages/ui/src/composed/OrganizationProfile/index.tsx new file mode 100644 index 00000000000..fdbad295a9e --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/index.tsx @@ -0,0 +1,10 @@ +export { OrganizationProfileProvider } from './OrganizationProfileProvider'; +export { OrganizationProfileGeneralPanel } from './General'; +export { OrganizationProfileMembersPanel } from './Members'; +export { OrganizationProfileBillingPanel } from './Billing'; +export { OrganizationProfileAPIKeysPanel } from './APIKeys'; +export { OrganizationProfileSecurityPanel } from './Security'; +export { OrganizationProfileProfileSection } from './GeneralOrganizationProfile'; +export { OrganizationProfileDomainsSection } from './GeneralVerifiedDomains'; +export { OrganizationProfileLeaveSection } from './GeneralLeaveOrganization'; +export { OrganizationProfileDeleteSection } from './GeneralDeleteOrganization'; diff --git a/packages/ui/src/composed/__tests__/OrganizationProfile.test.tsx b/packages/ui/src/composed/__tests__/OrganizationProfile.test.tsx new file mode 100644 index 00000000000..38133f9e5a6 --- /dev/null +++ b/packages/ui/src/composed/__tests__/OrganizationProfile.test.tsx @@ -0,0 +1,47 @@ +import type { ClerkPaginatedResponse, OrganizationMembershipResource } from '@clerk/shared/types'; +import { describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; + +import { OrganizationGeneralPage } from '../../components/OrganizationProfile/OrganizationGeneralPage'; + +const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + +describe('Experimental OrganizationProfile', () => { + describe('General page', () => { + it('renders the organization general page', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue( + Promise.resolve({ + data: [], + total_count: 0, + }), + ); + + render(, { wrapper }); + screen.getByText('General'); + }); + + it('shows organization name', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue( + Promise.resolve({ + data: [], + total_count: 0, + }), + ); + + render(, { wrapper }); + screen.getByText('TestOrg'); + }); + }); +}); diff --git a/packages/ui/src/composed/__tests__/OrganizationProfileSections.test.tsx b/packages/ui/src/composed/__tests__/OrganizationProfileSections.test.tsx new file mode 100644 index 00000000000..eedfbdf96ad --- /dev/null +++ b/packages/ui/src/composed/__tests__/OrganizationProfileSections.test.tsx @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; + +import { clearFetchCache } from '../../hooks'; +import { OrganizationProfileGeneralPanel } from '../OrganizationProfile/General'; +import { OrganizationProfileDeleteSection } from '../OrganizationProfile/GeneralDeleteOrganization'; +import { OrganizationProfileLeaveSection } from '../OrganizationProfile/GeneralLeaveOrganization'; +import { OrganizationProfileProfileSection } from '../OrganizationProfile/GeneralOrganizationProfile'; +import { OrganizationProfileDomainsSection } from '../OrganizationProfile/GeneralVerifiedDomains'; + +const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + +describe('OrganizationProfile composed sections', () => { + beforeEach(() => { + clearFetchCache(); + }); + + describe('General — section composition mode', () => { + it('renders only declared sections', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withOrganizationDomains(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + const { queryByText } = render( + + + , + { wrapper }, + ); + + screen.getByText('TestOrg'); + expect(queryByText(/verified domains/i)).not.toBeInTheDocument(); + }); + + it('renders header', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + render( + + + , + { wrapper }, + ); + + screen.getByText('General'); + }); + + it('OrganizationProfileDomainsSection renders when domains enabled and user has permission', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withOrganizationDomains(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + render( + + + , + { wrapper }, + ); + + await waitFor(() => screen.getByText(/verified domains/i)); + }); + + it('OrganizationProfileDeleteSection renders null when adminDeleteEnabled is false', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + const { queryByText } = render( + + + + , + { wrapper }, + ); + + screen.getByText('TestOrg'); + expect(queryByText(/delete organization/i)).not.toBeInTheDocument(); + }); + + it('OrganizationProfileLeaveSection renders leave button', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + render( + + + , + { wrapper }, + ); + + expect(screen.getAllByText(/leave organization/i).length).toBeGreaterThan(0); + }); + + it('renders custom content between sections', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + render( + + +
Custom org content
+ +
, + { wrapper }, + ); + + expect(screen.getByTestId('custom-banner')).toBeInTheDocument(); + screen.getByText('Custom org content'); + }); + }); + + describe('General — section outside page', () => { + it('useRequirePage throws when rendered outside a page component', async () => { + const { wrapper } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + expect(() => render(, { wrapper })).toThrow( + ' must be rendered inside a page component', + ); + }); + }); +}); diff --git a/packages/ui/src/composed/__tests__/composed-provider-wiring.test.tsx b/packages/ui/src/composed/__tests__/composed-provider-wiring.test.tsx new file mode 100644 index 00000000000..5bdb1b27820 --- /dev/null +++ b/packages/ui/src/composed/__tests__/composed-provider-wiring.test.tsx @@ -0,0 +1,569 @@ +import { act } from '@testing-library/react'; +import { useContext } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; +import { useModuleManager, useOptions } from '@/ui/contexts'; +import { useAppearance } from '@/ui/customizables/AppearanceContext'; +import { useRouter } from '@/ui/router'; + +import { OrganizationProfileContext } from '../../contexts/components/OrganizationProfile'; +import { UserProfileContext } from '../../contexts/components/UserProfile'; +import { clearFetchCache } from '../../hooks'; +import { OrganizationProfileProvider } from '../OrganizationProfile/OrganizationProfileProvider'; +import { fallbackModuleManager } from '../ProfileProviderShell'; +import { UserProfileSecurityPanel } from '../UserProfile/Security'; +import { UserProfilePasswordSection } from '../UserProfile/SecurityPassword'; +import { UserProfileProvider } from '../UserProfile/UserProfileProvider'; + +function patchEnvironment(clerk: any, env: any) { + Object.defineProperty(clerk, '__internal_environment', { value: env, configurable: true }); +} + +function ModuleManagerProbe() { + const mm = useModuleManager(); + return ( +
+ ); +} + +function RouterProbe() { + const router = useRouter(); + return ( +
+ ); +} + +describe('UserProfileProvider wiring', () => { + const { createFixtures } = bindCreateFixtures('UserProfile'); + + beforeEach(() => { + clearFetchCache(); + }); + + it("provides the clerk instance's moduleManager to children", async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + render( + + + , + { wrapper }, + ); + + const probe = screen.getByTestId('mm-probe'); + expect(probe.dataset.hasMm).toBe('true'); + }); + + it('falls back to fallbackModuleManager when the clerk instance exposes no moduleManager', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + // Simulate a clerk-js too old to expose the getter (returns undefined). + Object.defineProperty(fixtures.clerk, '__internal_moduleManager', { + value: undefined, + configurable: true, + }); + + function FallbackProbe() { + const mm = useModuleManager(); + return ( +
+ ); + } + + render( + + + , + { wrapper }, + ); + + expect(screen.getByTestId('mm-fallback-probe').dataset.isFallback).toBe('true'); + }); + + it('provides a router that delegates to clerk.navigate', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + render( + + + , + { wrapper }, + ); + + const probe = screen.getByTestId('router-probe'); + expect(probe.dataset.hasNavigate).toBe('true'); + expect(probe.dataset.hasBaseNavigate).toBe('true'); + }); + + // The shell does NOT track the host app URL. Composed has no Clerk-internal + // navigation between sections (consumer remounts ), so there's + // no meaningful currentPath to snapshot — and observing the consumer's URL + // would just trigger spurious navigation-keyed effects. + describe('router.currentPath is decoupled from the host URL', () => { + let originalPath: string; + + beforeEach(() => { + originalPath = window.location.pathname; + }); + + afterEach(() => { + window.history.replaceState(null, '', originalPath); + }); + + function CurrentPathProbe() { + const router = useRouter(); + return ( +
+ ); + } + + it('does not snapshot window.location.pathname into router.currentPath', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + window.history.replaceState(null, '', '/page-a'); + + render( + + + , + { wrapper }, + ); + + expect(screen.getByTestId('path-probe').dataset.current).toBe(''); + }); + + it('does not update router.currentPath on popstate', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + render( + + + , + { wrapper }, + ); + + act(() => { + window.history.pushState(null, '', '/page-b'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + + expect(screen.getByTestId('path-probe').dataset.current).toBe(''); + }); + }); + + // The end-to-end proof that resolution actually feeds a dynamic import: render + // the real composed password section, enable zxcvbn strength, type a password, + // and assert the resolved manager's `import` fires for the zxcvbn module. This + // exercises the whole chain UserProfileProvider -> ProfileProviderShell -> + // ModuleManagerProvider -> useModuleManager -> usePassword -> loadZxcvbn. + // (This is the deterministic in-process analog of a Playwright flow, which + // cannot run this path without a backend instance that has `show_zxcvbn` on.) + it('feeds the resolved moduleManager into the composed password strength import', async () => { + // Never settles, so the un-caught `.then` in createValidatePassword does not + // surface as an unhandled rejection; we only care that `import` was invoked. + const importSpy = vi.fn(() => new Promise(() => {})); + + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withPassword(); + f.withPasswordComplexity({ show_zxcvbn: true }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + Object.defineProperty(fixtures.clerk, '__internal_moduleManager', { + value: { import: importSpy }, + configurable: true, + }); + + const { userEvent, getByRole, getByLabelText } = render( + + + + + , + { wrapper }, + ); + + await userEvent.click(getByRole('button', { name: /set password/i })); + await userEvent.type(getByLabelText(/new password/i), 'weak'); + + await waitFor(() => { + expect(importSpy).toHaveBeenCalledWith('@zxcvbn-ts/core'); + }); + }); + + it('returns null when user is not loaded', async () => { + const { wrapper, fixtures } = await createFixtures(); + patchEnvironment(fixtures.clerk, fixtures.environment); + + const { container } = render( + +
+ , + { wrapper }, + ); + + expect(screen.queryByTestId('should-not-render')).not.toBeInTheDocument(); + }); + + it('cascades globalAppearance from ClerkProvider into composed theme', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + // Simulate ClerkProvider setting appearance with colorPrimary + fixtures.clerk.__internal_getOption = vi.fn((key: string) => { + if (key === 'appearance') { + return { variables: { colorPrimary: '#ff0000' } }; + } + return undefined; + }); + + function AppearanceProbe() { + const { parsedInternalTheme } = useAppearance(); + return ( +
+ ); + } + + render( + + + , + { wrapper }, + ); + + const probe = screen.getByTestId('appearance-probe'); + // #ff0000 = hsla(0, 100%, 50%, 1) — the global appearance should cascade + expect(probe.dataset.colorPrimary).toBe('hsla(0, 100%, 50%, 1)'); + }); + + it('threads localization from ClerkProvider into composed OptionsProvider', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + const expectedLocalization = { locale: 'fr-FR', signIn: { start: { title: 'Bienvenue' } } }; + const expectedSupportEmail = 'help@clerk.dev'; + fixtures.clerk.__internal_getOption = vi.fn((key: string) => { + if (key === 'localization') { + return expectedLocalization; + } + if (key === 'supportEmail') { + return expectedSupportEmail; + } + return undefined; + }); + + function OptionsProbe() { + const options = useOptions(); + return ( +
+ ); + } + + render( + + + , + { wrapper }, + ); + + const probe = screen.getByTestId('options-probe'); + expect(probe.dataset.locale).toBe('fr-FR'); + expect(probe.dataset.supportEmail).toBe(expectedSupportEmail); + }); + + it('returns null when environment is missing', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, undefined); + + render( + +
+ , + { wrapper }, + ); + + expect(screen.queryByTestId('should-not-render')).not.toBeInTheDocument(); + }); + + it('forwards apiKeysProps into the UserProfile context', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + function ApiKeysProbe() { + const ctx = useContext(UserProfileContext); + return ( +
+ ); + } + + render( + + + , + { wrapper }, + ); + + expect(JSON.parse(screen.getByTestId('apikeys-probe').dataset.apiKeys || 'null')).toEqual({ perPage: 5 }); + }); +}); + +describe('OrganizationProfileProvider wiring', () => { + const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + + beforeEach(() => { + clearFetchCache(); + }); + + it("provides the clerk instance's moduleManager to children", async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + first_name: 'Test', + last_name: 'User', + organization_memberships: [{ name: 'TestOrg' }], + }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + render( + + + , + { wrapper }, + ); + + const probe = screen.getByTestId('mm-probe'); + expect(probe.dataset.hasMm).toBe('true'); + }); + + it('falls back to fallbackModuleManager when the clerk instance exposes no moduleManager', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + first_name: 'Test', + last_name: 'User', + organization_memberships: [{ name: 'TestOrg' }], + }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + Object.defineProperty(fixtures.clerk, '__internal_moduleManager', { + value: undefined, + configurable: true, + }); + + function FallbackProbe() { + const mm = useModuleManager(); + return ( +
+ ); + } + + render( + + + , + { wrapper }, + ); + + expect(screen.getByTestId('org-mm-fallback-probe').dataset.isFallback).toBe('true'); + }); + + it('returns null when organization is not loaded', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + const { container } = render( + +
+ , + { wrapper }, + ); + + expect(screen.queryByTestId('should-not-render')).not.toBeInTheDocument(); + }); + + it('forwards afterLeaveOrganizationUrl and apiKeysProps into the OrganizationProfile context', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + first_name: 'Test', + last_name: 'User', + organization_memberships: [{ name: 'TestOrg' }], + }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + function OrgProbe() { + const ctx = useContext(OrganizationProfileContext); + return ( +
+ ); + } + + render( + + + , + { wrapper }, + ); + + const probe = screen.getByTestId('org-probe'); + expect(probe.dataset.afterLeave).toBe('/bye'); + expect(JSON.parse(probe.dataset.apiKeys || 'null')).toEqual({ showDescription: true }); + }); +}); + +// The fallback exists to fail loudly instead of silently resolving `undefined`. +// This is the contract the whole getter refactor protects: when a too-old +// clerk-js exposes no manager, the first dynamic import (Web3, billing, +// password strength) must reject with a stable, diagnosable code rather than +// degrade to an opaque access on a missing module. +describe('fallbackModuleManager', () => { + it('rejects dynamic imports with a composed_module_manager_unavailable code', async () => { + await expect(fallbackModuleManager.import('@zxcvbn-ts/core')).rejects.toMatchObject({ + code: 'composed_module_manager_unavailable', + }); + }); +}); + +// Both providers were changed identically to resolve the manager through the +// live `clerk.__internal_moduleManager` getter, which crosses bundle boundaries +// regardless of how many @clerk/shared copies are installed. A registry keyed by +// module-scoped state would silently return the wrong manager (or undefined) when +// the read-side @clerk/shared is a different physical copy than the write-side +// one. Exposing a getter-only manager (distinct from anything the real Clerk +// instance registered) and surfacing it proves the read channel is the getter, +// not a shared registry — pinned here for both providers. +describe('moduleManager getter resolution', () => { + beforeEach(() => { + clearFetchCache(); + }); + + const cases = [ + { + name: 'UserProfileProvider', + component: 'UserProfile' as const, + Provider: UserProfileProvider, + testId: 'mm-getter-probe', + setup: (f: any) => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }, + }, + { + name: 'OrganizationProfileProvider', + component: 'OrganizationProfile' as const, + Provider: OrganizationProfileProvider, + testId: 'org-mm-getter-probe', + setup: (f: any) => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + first_name: 'Test', + last_name: 'User', + organization_memberships: [{ name: 'TestOrg' }], + }); + }, + }, + ]; + + it.each(cases)( + '$name resolves the moduleManager from clerk.__internal_moduleManager (getter), not a shared registry', + async ({ component, Provider, testId, setup }) => { + const { createFixtures } = bindCreateFixtures(component); + const getterImport = vi.fn(() => Promise.resolve(undefined)); + const getterModuleManager = { import: getterImport }; + + const { wrapper, fixtures } = await createFixtures(setup); + patchEnvironment(fixtures.clerk, fixtures.environment); + fixtures.clerk.organization?.getDomains?.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + Object.defineProperty(fixtures.clerk, '__internal_moduleManager', { + value: getterModuleManager, + configurable: true, + }); + + function ModuleManagerIdentityProbe() { + const mm = useModuleManager(); + return ( +
+ ); + } + + render( + + + , + { wrapper }, + ); + + expect(screen.getByTestId(testId).dataset.fromGetter).toBe('true'); + }, + ); +}); diff --git a/packages/ui/src/composed/__tests__/style-cache-sharing.test.tsx b/packages/ui/src/composed/__tests__/style-cache-sharing.test.tsx new file mode 100644 index 00000000000..e2fd8e396df --- /dev/null +++ b/packages/ui/src/composed/__tests__/style-cache-sharing.test.tsx @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render } from '@/test/utils'; +import { getStyleCache } from '@/ui/internal/styleCacheStore'; + +import { clearFetchCache } from '../../hooks'; +import { OrganizationProfileProvider } from '../OrganizationProfile/OrganizationProfileProvider'; +import { UserProfileProvider } from '../UserProfile/UserProfileProvider'; + +function patchEnvironment(clerk: any, env: any) { + Object.defineProperty(clerk, '__internal_environment', { value: env, configurable: true }); +} + +describe('composed style cache sharing', () => { + const { createFixtures } = bindCreateFixtures('UserProfile'); + + beforeEach(() => { + clearFetchCache(); + }); + + it('sibling UserProfile + OrganizationProfile roots share one emotion cache per clerk instance', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + first_name: 'Test', + last_name: 'User', + organization_memberships: [{ name: 'TestOrg' }], + }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + render( + <> + +
+ + +
+ + , + { wrapper }, + ); + + const cache = getStyleCache(fixtures.clerk); + expect(cache).toBeDefined(); + expect(cache?.key).toBe('cl-internal'); + + // Second sibling must reuse the cache the first sibling set on the WeakMap, + // rather than creating a new one. The WeakMap is the sharing channel. + const cacheAgain = getStyleCache(fixtures.clerk); + expect(cacheAgain).toBe(cache); + }); + + it('distinct clerk instances get distinct caches', async () => { + const first = await createFixtures(f => { + f.withUser({ email_addresses: ['a@clerk.com'] }); + }); + patchEnvironment(first.fixtures.clerk, first.fixtures.environment); + + const second = await createFixtures(f => { + f.withUser({ email_addresses: ['b@clerk.com'] }); + }); + patchEnvironment(second.fixtures.clerk, second.fixtures.environment); + + render( + +
+ , + { wrapper: first.wrapper }, + ); + + render( + +
+ , + { wrapper: second.wrapper }, + ); + + const cacheA = getStyleCache(first.fixtures.clerk); + const cacheB = getStyleCache(second.fixtures.clerk); + + expect(cacheA).toBeDefined(); + expect(cacheB).toBeDefined(); + expect(cacheA).not.toBe(cacheB); + }); + + it('first mount initializes the cache with the clerk-provided nonce', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + const expectedNonce = 'test-nonce-abc123'; + const originalGetOption = fixtures.clerk.__internal_getOption.bind(fixtures.clerk); + fixtures.clerk.__internal_getOption = (key: string) => { + if (key === 'nonce') { + return expectedNonce; + } + return originalGetOption(key); + }; + + render( + +
+ , + { wrapper }, + ); + + const cache = getStyleCache(fixtures.clerk); + expect(cache?.sheet.nonce).toBe(expectedNonce); + }); + + it('wraps emitted CSS in @layer when appearance.cssLayerName is configured', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + fixtures.clerk.__internal_getOption = vi.fn((key: string) => { + if (key === 'appearance') { + return { cssLayerName: 'app-clerk' }; + } + return undefined; + }); + + render( + +
+ , + { wrapper }, + ); + + const cache = getStyleCache(fixtures.clerk); + expect(cache).toBeDefined(); + if (!cache) { + return; + } + + // Insert a style and read what actually landed in the cache's