diff --git a/.changeset/profile-composed-shell.md b/.changeset/profile-composed-shell.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/profile-composed-shell.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/composed/APIKeysSection.tsx b/packages/ui/src/composed/APIKeysSection.tsx new file mode 100644 index 00000000000..3e1369dcef8 --- /dev/null +++ b/packages/ui/src/composed/APIKeysSection.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { Suspense, type ComponentType, type ReactNode } from 'react'; + +import { CardStateProvider } from '../elements/contexts'; + +export function APIKeysSection({ page: Page }: { page: ComponentType }): ReactNode { + return ( + + + + + + ); +} diff --git a/packages/ui/src/composed/BillingSection.tsx b/packages/ui/src/composed/BillingSection.tsx new file mode 100644 index 00000000000..f6c30a1139e --- /dev/null +++ b/packages/ui/src/composed/BillingSection.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { Suspense, type ComponentType, type ReactNode } from 'react'; + +import { RouteContext } from '../router/RouteContext'; +import { useBillingRouter } from './useBillingRouter'; + +type BillingSectionProps = { + billing: ComponentType; + plans: ComponentType; + statement: ComponentType; + paymentAttempt: ComponentType; +}; + +export function BillingSection({ + billing: Billing, + plans: Plans, + statement: Statement, + paymentAttempt: PaymentAttempt, +}: BillingSectionProps): ReactNode { + const { router, route } = useBillingRouter(); + + let content: ReactNode; + switch (route.page) { + case 'plans': + content = ; + break; + case 'statement': + content = ; + break; + case 'payment-attempt': + content = ; + break; + default: + content = ; + } + + return ( + + {content} + + ); +} diff --git a/packages/ui/src/composed/PageContext.tsx b/packages/ui/src/composed/PageContext.tsx new file mode 100644 index 00000000000..b16f319d803 --- /dev/null +++ b/packages/ui/src/composed/PageContext.tsx @@ -0,0 +1,5 @@ +import { createContext } from 'react'; + +type PageId = 'account' | 'security' | 'general'; + +export const PageContext = createContext(null); diff --git a/packages/ui/src/composed/ProfileProviderShell.tsx b/packages/ui/src/composed/ProfileProviderShell.tsx new file mode 100644 index 00000000000..d1abf54b29c --- /dev/null +++ b/packages/ui/src/composed/ProfileProviderShell.tsx @@ -0,0 +1,150 @@ +'use client'; + +// Composed UserProfile / OrganizationProfile mount outside the clerk-js portal +// tree, so this shell rebuilds the providers normally split between +// `LazyProviders` and `LazyComponentRenderer` / `LazyModalRenderer` in +// `packages/ui/src/lazyModules/providers.tsx`. `ClerkContextProvider` is +// intentionally omitted — the consumer's `` supplies `clerk` via +// `useClerk()`. The emotion cache is keyed per clerk instance in +// `styleCacheStore` so sibling composed roots don't duplicate style insertions. + +import { ClerkRuntimeError } from '@clerk/shared/error'; +import type { ModuleManager } from '@clerk/shared/moduleManager'; +import type { EnvironmentResource, LoadedClerk } from '@clerk/shared/types'; +// eslint-disable-next-line no-restricted-imports +import { CacheProvider } from '@emotion/react'; +import type { PropsWithChildren, ReactNode } from 'react'; +import { useMemo } from 'react'; + +import { AppearanceProvider } from '@/ui/customizables/AppearanceContext'; +import { FlowMetadataProvider } from '@/ui/elements/contexts'; +import type { Appearance, Elements } from '@/ui/internal/appearance'; +import { getStyleCache, setStyleCache } from '@/ui/internal/styleCacheStore'; +import { RouteContext } from '@/ui/router/RouteContext'; +import { InternalThemeProvider } from '@/ui/styledSystem'; +import { createEmotionCache } from '@/ui/styledSystem/createEmotionCache'; +import { extractCssLayerNameFromAppearance } from '@/ui/utils/extractCssLayerNameFromAppearance'; + +import { EnvironmentProvider } from '../contexts/EnvironmentContext'; +import { ModuleManagerProvider } from '../contexts/ModuleManagerContext'; +import { OptionsProvider } from '../contexts/OptionsContext'; +import { AppearanceOverrides } from '../elements/AppearanceOverrides'; +import { createComposedRouter } from './stubRouter'; + +// Used when `clerk.__internal_moduleManager` is `undefined`. In a correctly wired app clerk-js +// exposes its ModuleManager through that getter, so reaching this means the loaded clerk-js is too +// old to expose it (an older clerk-js also predates composed profiles entirely). Fail loudly on the +// first dynamic import (Web3, billing, password strength) instead of silently resolving `undefined` +// and surfacing later as an opaque access on the missing module. +export const fallbackModuleManager: ModuleManager = { + import: () => + Promise.reject( + new ClerkRuntimeError( + 'Composed profile components could not resolve a Clerk module manager: this Clerk instance does not expose one. This usually means the loaded @clerk/clerk-js is too old to support composed profiles.', + { code: 'composed_module_manager_unavailable' }, + ), + ), +}; + +const composedOverrides: Elements = { + profilePageContent: { padding: 0 }, +}; + +type ProfileProviderShellProps = PropsWithChildren<{ + clerk: LoadedClerk; + environment: EnvironmentResource; + moduleManager: ModuleManager; + appearanceKey: 'userProfile' | 'organizationProfile'; + flow: 'userProfile' | 'organizationProfile'; + globalAppearance: Appearance | undefined; + appearance?: Appearance; +}>; + +type SharedStyleCacheProviderProps = PropsWithChildren<{ + clerk: LoadedClerk; + nonce?: string; + cssLayerName?: string; +}>; + +// One emotion cache per clerk instance, so sibling composed roots share inserts. +function SharedStyleCacheProvider({ clerk, nonce, cssLayerName, children }: SharedStyleCacheProviderProps): ReactNode { + const cache = useMemo(() => { + const existing = getStyleCache(clerk); + if (existing) { + return existing; + } + const next = createEmotionCache({ nonce, cssLayerName }); + setStyleCache(clerk, next); + return next; + }, [clerk, nonce, cssLayerName]); + + return {children}; +} + +export function ProfileProviderShell({ + children, + clerk, + environment, + moduleManager, + appearanceKey, + flow, + globalAppearance, + appearance, +}: ProfileProviderShellProps): ReactNode { + // currentPath is left empty: composed has no Clerk-internal navigation. Each + // section owns its own CardStateProvider, so errors clear on section unmount + // (single-section mounting). Side-by-side sections keep independent error + // state — a consumer URL change wouldn't be a meaningful signal to clear + // either, so observing it would only cause spurious clears. + const router = useMemo(() => createComposedRouter(clerk.navigate), [clerk]); + // Match the portal path's normalization (Components.tsx:209) so a cssLayerName + // nested inside appearance.theme gets hoisted to top-level for @layer wrapping. + const normalizedGlobalAppearance = useMemo( + () => extractCssLayerNameFromAppearance(globalAppearance), + [globalAppearance], + ); + const options = useMemo( + () => ({ + localization: clerk.__internal_getOption('localization'), + supportEmail: clerk.__internal_getOption('supportEmail'), + }), + [clerk], + ); + + return ( + + {/* parsed appearance for cl-* styled components */} + + {/* flow= for Flow.Root/Part data-clerk-* selectors */} + + {/* Emotion ThemeProvider over parsed theme */} + + {/* dynamic-import bridge (Web3) */} + + {/* threads localization + supportEmail from the consumer's */} + + {/* read by useEnvironment() across MFA/account sections */} + + {/* router stub: navigate→clerk.navigate, matches/refresh no-op */} + + {/* zero out profilePageContent padding when embedded */} + {children} + + + + + + + + + ); +} diff --git a/packages/ui/src/composed/__tests__/stub-limitations.test.ts b/packages/ui/src/composed/__tests__/stub-limitations.test.ts new file mode 100644 index 00000000000..fffd9d61236 --- /dev/null +++ b/packages/ui/src/composed/__tests__/stub-limitations.test.ts @@ -0,0 +1,131 @@ +import { renderHook, act } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createComposedRouter, stubRouter } from '../stubRouter'; +import { useBillingRouter } from '../useBillingRouter'; + +describe('createComposedRouter', () => { + it('navigate delegates to clerkNavigate for same-origin paths', async () => { + const clerkNavigate = vi.fn().mockResolvedValue(undefined); + const router = createComposedRouter(clerkNavigate); + + await router.navigate('/dashboard'); + + expect(clerkNavigate).toHaveBeenCalledWith('/dashboard'); + }); + + it('navigate delegates to clerkNavigate for relative paths', async () => { + const clerkNavigate = vi.fn().mockResolvedValue(undefined); + const router = createComposedRouter(clerkNavigate); + + await router.navigate('../'); + + expect(clerkNavigate).toHaveBeenCalledWith('../'); + }); + + it('navigate delegates to clerkNavigate for external URLs', async () => { + const clerkNavigate = vi.fn().mockResolvedValue(undefined); + const router = createComposedRouter(clerkNavigate); + + await router.navigate('https://external.example.com/callback'); + + expect(clerkNavigate).toHaveBeenCalledWith('https://external.example.com/callback'); + }); + + it('baseNavigate delegates to clerkNavigate with URL href', async () => { + const clerkNavigate = vi.fn().mockResolvedValue(undefined); + const router = createComposedRouter(clerkNavigate); + + await router.baseNavigate(new URL('https://example.com/path')); + + expect(clerkNavigate).toHaveBeenCalledWith('https://example.com/path'); + }); + + it('resolve produces URLs relative to current location', () => { + const router = createComposedRouter(vi.fn()); + + const resolved = router.resolve('/some-path'); + expect(resolved.pathname).toBe('/some-path'); + }); +}); + +describe('createComposedRouter — AIO-only APIs throw in dev', () => { + it('matches() throws', () => { + const router = createComposedRouter(vi.fn()); + expect(() => router.matches('/foo')).toThrow(/not supported inside composed sections/); + }); + + it('refresh() throws', () => { + const router = createComposedRouter(vi.fn()); + expect(() => router.refresh()).toThrow(/not supported inside composed sections/); + }); + + it('getMatchData() throws', () => { + const router = createComposedRouter(vi.fn()); + expect(() => router.getMatchData('/foo')).toThrow(/not supported inside composed sections/); + }); +}); + +describe('stubRouter fallback', () => { + it('is created with window.location.assign as navigator', () => { + // stubRouter is a pre-built instance that delegates to window.location.assign. + // We can't spy on window.location.assign in jsdom, but we verify it's a valid router. + expect(stubRouter.navigate).toBeDefined(); + expect(stubRouter.baseNavigate).toBeDefined(); + }); +}); + +describe('useBillingRouter — in-memory by design', () => { + // Composed billing routing is purely React state — the consumer owns the + // page URL. Trade-off: back/forward, refresh, and deep-links do not preserve + // sub-route or tab state. These tests pin that decision. + + let originalHash: string; + + afterEach(() => { + window.location.hash = originalHash ?? ''; + }); + + it('navigate() does not touch window.location.hash', async () => { + originalHash = window.location.hash; + const { result } = renderHook(() => useBillingRouter()); + + await act(async () => { + await result.current.router.navigate('plans'); + }); + + expect(window.location.hash).toBe(originalHash); + expect(result.current.route.page).toBe('plans'); + }); + + it('navigate() does not push a history entry', async () => { + const before = window.history.length; + const { result } = renderHook(() => useBillingRouter()); + + await act(async () => { + await result.current.router.navigate('statement/abc'); + }); + + expect(window.history.length).toBe(before); + }); +}); + +describe('createComposedRouter — SSR safety', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('resolve() does not throw when window is undefined', () => { + vi.stubGlobal('window', undefined); + + const router = createComposedRouter(vi.fn()); + expect(() => router.resolve('/some-path')).not.toThrow(); + expect(router.resolve('/some-path').pathname).toBe('/some-path'); + }); + + it('stubRouter.navigate is a no-op (does not throw) when window is undefined', async () => { + vi.stubGlobal('window', undefined); + + await expect(stubRouter.navigate('/foo')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/ui/src/composed/createSection.tsx b/packages/ui/src/composed/createSection.tsx new file mode 100644 index 00000000000..fb2e6b89042 --- /dev/null +++ b/packages/ui/src/composed/createSection.tsx @@ -0,0 +1,14 @@ +'use client'; + +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/stubRouter.ts b/packages/ui/src/composed/stubRouter.ts new file mode 100644 index 00000000000..0c3484aca52 --- /dev/null +++ b/packages/ui/src/composed/stubRouter.ts @@ -0,0 +1,57 @@ +import type { RouteContextValue } from '../router/RouteContext'; + +const SSR_BASE = 'http://localhost/'; + +// Composed sections render outside the AIO Route/Switch tree, so the stub +// router has no path-matching state. Calls into these APIs almost always mean +// someone added a , navigateToFlowStart(), or refresh() inside a +// composed section — fail loudly in dev so that's caught at the test level +// instead of silently rendering null. +function unsupported(api: string): never { + throw new Error( + `[@clerk/ui composed] router.${api} is not supported inside composed sections. ` + + `Composed sections are leaf-only — , navigateToFlowStart(), router.refresh(), ` + + `getMatchData() and urlStateParam are AIO-only. Move routing to the consumer.`, + ); +} + +function maybeUnsupported(api: string, prodValue: T): T { + if (typeof __DEV__ === 'undefined' || __DEV__) { + unsupported(api); + } + return prodValue; +} + +export function createComposedRouter( + clerkNavigate: (to: string) => Promise | void, + currentPath: string = '', +): RouteContextValue { + return { + basePath: '', + startPath: '', + flowStartPath: '', + fullPath: '', + indexPath: '', + currentPath, + matches: () => maybeUnsupported('matches()', false), + navigate: async (to: string) => { + await clerkNavigate(to); + }, + baseNavigate: async (toURL: URL) => { + await clerkNavigate(toURL.href); + }, + resolve: (to: string) => new URL(to, typeof window !== 'undefined' ? window.location.href : SSR_BASE), + refresh: () => maybeUnsupported('refresh()', undefined), + params: {}, + queryString: '', + queryParams: {}, + getMatchData: () => maybeUnsupported('getMatchData()', false), + }; +} + +export const stubRouter: RouteContextValue = createComposedRouter(to => { + if (typeof window !== 'undefined') { + 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..bb9f03efb1b --- /dev/null +++ b/packages/ui/src/composed/useBillingRouter.ts @@ -0,0 +1,98 @@ +// In-memory routing for composed billing — intentionally not URL-backed. The +// consumer owns the page URL, so we don't touch hash/history. Trade-off: +// back/forward, refresh, and deep-links don't preserve sub-route or tab state. +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(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(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..e9f4c034f08 --- /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; +}