Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/profile-composed-shell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
15 changes: 15 additions & 0 deletions packages/ui/src/composed/APIKeysSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<CardStateProvider>
<Suspense fallback={null}>
<Page />
</Suspense>
</CardStateProvider>
);
}
43 changes: 43 additions & 0 deletions packages/ui/src/composed/BillingSection.tsx
Original file line number Diff line number Diff line change
@@ -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 = <Plans />;
break;
case 'statement':
content = <Statement />;
break;
case 'payment-attempt':
content = <PaymentAttempt />;
break;
default:
content = <Billing />;
}

return (
<RouteContext.Provider value={router}>
<Suspense fallback={null}>{content}</Suspense>
</RouteContext.Provider>
);
}
5 changes: 5 additions & 0 deletions packages/ui/src/composed/PageContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createContext } from 'react';

type PageId = 'account' | 'security' | 'general';

export const PageContext = createContext<PageId | null>(null);
150 changes: 150 additions & 0 deletions packages/ui/src/composed/ProfileProviderShell.tsx
Original file line number Diff line number Diff line change
@@ -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 `<ClerkProvider>` 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 <CacheProvider value={cache}>{children}</CacheProvider>;
}

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 (
<SharedStyleCacheProvider
clerk={clerk}
// nonce lives on IsomorphicClerkOptions, not ClerkOptions, so the typed K-constraint rejects it
nonce={(clerk as any).__internal_getOption('nonce')}
cssLayerName={normalizedGlobalAppearance?.cssLayerName}
>
{/* parsed appearance for cl-* styled components */}
<AppearanceProvider
appearanceKey={appearanceKey}
globalAppearance={normalizedGlobalAppearance}
appearance={appearance}
>
{/* flow= for Flow.Root/Part data-clerk-* selectors */}
<FlowMetadataProvider flow={flow}>
{/* Emotion ThemeProvider over parsed theme */}
<InternalThemeProvider>
{/* dynamic-import bridge (Web3) */}
<ModuleManagerProvider moduleManager={moduleManager}>
{/* threads localization + supportEmail from the consumer's <ClerkProvider> */}
<OptionsProvider value={options}>
{/* read by useEnvironment() across MFA/account sections */}
<EnvironmentProvider value={environment}>
{/* router stub: navigate→clerk.navigate, matches/refresh no-op */}
<RouteContext.Provider value={router}>
{/* zero out profilePageContent padding when embedded */}
<AppearanceOverrides elements={composedOverrides}>{children}</AppearanceOverrides>
</RouteContext.Provider>
</EnvironmentProvider>
</OptionsProvider>
</ModuleManagerProvider>
</InternalThemeProvider>
</FlowMetadataProvider>
</AppearanceProvider>
</SharedStyleCacheProvider>
);
}
131 changes: 131 additions & 0 deletions packages/ui/src/composed/__tests__/stub-limitations.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
14 changes: 14 additions & 0 deletions packages/ui/src/composed/createSection.tsx
Original file line number Diff line number Diff line change
@@ -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 <Component />;
}
Section.displayName = name;
return Section;
}
Loading
Loading