Skip to content
Open
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
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);
190 changes: 190 additions & 0 deletions packages/ui/src/composed/ProfileProviderShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
'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 { logger } from '@clerk/shared/logger';
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 { getStyleCacheEntry, 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' },
),
),
};

type ClerkWithInternalEnvironment = {
__internal_environment?: EnvironmentResource | null;
};

/**
* Resolves the clerk-js runtime state (environment + module manager) that the
* composed profile shell needs. Composed UI is bundled into the consumer app but
* clerk-js is hotloaded separately, so an app can bundle composed components that
* are newer than the loaded clerk-js. `moduleManager` falls back to a loud stub;
* `environment` can only be absent, so once clerk has finished loading a missing
* runtime is a real version mismatch and gets a one-time warning instead of a
* silent blank render.
*/
export function resolveComposedClerkRuntime(
clerk: LoadedClerk,
clerkLoaded: boolean,
): { environment: EnvironmentResource | null | undefined; moduleManager: ModuleManager } {
// SAFETY: __internal_environment is a real clerk-js getter absent from the shared LoadedClerk type; narrowing (not `any`) keeps it typed.
const environment = (clerk as LoadedClerk & ClerkWithInternalEnvironment).__internal_environment;
const moduleManager = clerk.__internal_moduleManager ?? fallbackModuleManager;

if (clerkLoaded && (!environment || clerk.__internal_moduleManager === undefined)) {
logger.warnOnce(
'Clerk: Composed profile components could not read the runtime state (environment/module manager) from the loaded @clerk/clerk-js, so nothing will render. This usually means the loaded clerk-js is older than the composed components bundled in your app. Upgrade @clerk/clerk-js (or your framework SDK) to a version that supports composed profiles.',
);
}

return { environment, moduleManager };
}

type ClerkWithNonceOption = { __internal_getOption(key: string): string | undefined };

function readNonceOption(clerk: LoadedClerk): string | undefined {
// SAFETY: nonce is a runtime clerk-js option whose key is absent from the typed ClerkOptions; narrowing (not `any`) keeps the return typed. Called as a method to preserve `this`.
return (clerk as unknown as ClerkWithNonceOption).__internal_getOption('nonce');
}

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.
// Reuse the stored cache only when it was built from the same nonce/cssLayerName;
// a change to either rebuilds it (mirroring the AIO StyleCacheProvider) instead of
// pinning whatever the first-mounted sibling saw.
function SharedStyleCacheProvider({ clerk, nonce, cssLayerName, children }: SharedStyleCacheProviderProps): ReactNode {
const cache = useMemo(() => {
const existing = getStyleCacheEntry(clerk);
if (existing && existing.nonce === nonce && existing.cssLayerName === cssLayerName) {
return existing.cache;
}
const next = createEmotionCache({ nonce, cssLayerName });
setStyleCache(clerk, { cache: next, nonce, cssLayerName });
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 appearance normalization 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={readNonceOption(clerk)}
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();
});
});
Loading
Loading