Skip to content

Commit 669a561

Browse files
committed
feat(ui): add composed profile shell, providers infra and stub router
1 parent e791dc6 commit 669a561

9 files changed

Lines changed: 529 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
'use client';
2+
3+
import { Suspense, type ComponentType, type ReactNode } from 'react';
4+
5+
import { CardStateProvider } from '../elements/contexts';
6+
7+
export function APIKeysSection({ page: Page }: { page: ComponentType }): ReactNode {
8+
return (
9+
<CardStateProvider>
10+
<Suspense fallback={null}>
11+
<Page />
12+
</Suspense>
13+
</CardStateProvider>
14+
);
15+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
'use client';
2+
3+
import { Suspense, type ComponentType, type ReactNode } from 'react';
4+
5+
import { RouteContext } from '../router/RouteContext';
6+
import { useBillingRouter } from './useBillingRouter';
7+
8+
type BillingSectionProps = {
9+
billing: ComponentType;
10+
plans: ComponentType;
11+
statement: ComponentType;
12+
paymentAttempt: ComponentType;
13+
};
14+
15+
export function BillingSection({
16+
billing: Billing,
17+
plans: Plans,
18+
statement: Statement,
19+
paymentAttempt: PaymentAttempt,
20+
}: BillingSectionProps): ReactNode {
21+
const { router, route } = useBillingRouter();
22+
23+
let content: ReactNode;
24+
switch (route.page) {
25+
case 'plans':
26+
content = <Plans />;
27+
break;
28+
case 'statement':
29+
content = <Statement />;
30+
break;
31+
case 'payment-attempt':
32+
content = <PaymentAttempt />;
33+
break;
34+
default:
35+
content = <Billing />;
36+
}
37+
38+
return (
39+
<RouteContext.Provider value={router}>
40+
<Suspense fallback={null}>{content}</Suspense>
41+
</RouteContext.Provider>
42+
);
43+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { createContext } from 'react';
2+
3+
type PageId = 'account' | 'security' | 'general';
4+
5+
export const PageContext = createContext<PageId | null>(null);
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
'use client';
2+
3+
// Composed UserProfile / OrganizationProfile mount outside the clerk-js portal
4+
// tree, so this shell rebuilds the providers normally split between
5+
// `LazyProviders` and `LazyComponentRenderer` / `LazyModalRenderer` in
6+
// `packages/ui/src/lazyModules/providers.tsx`. `ClerkContextProvider` is
7+
// intentionally omitted — the consumer's `<ClerkProvider>` supplies `clerk` via
8+
// `useClerk()`. The emotion cache is keyed per clerk instance in
9+
// `styleCacheStore` so sibling composed roots don't duplicate style insertions.
10+
11+
import { ClerkRuntimeError } from '@clerk/shared/error';
12+
import type { ModuleManager } from '@clerk/shared/moduleManager';
13+
import type { EnvironmentResource, LoadedClerk } from '@clerk/shared/types';
14+
// eslint-disable-next-line no-restricted-imports
15+
import { CacheProvider } from '@emotion/react';
16+
import type { PropsWithChildren, ReactNode } from 'react';
17+
import { useMemo } from 'react';
18+
19+
import { AppearanceProvider } from '@/ui/customizables/AppearanceContext';
20+
import { FlowMetadataProvider } from '@/ui/elements/contexts';
21+
import type { Appearance, Elements } from '@/ui/internal/appearance';
22+
import { getStyleCache, setStyleCache } from '@/ui/internal/styleCacheStore';
23+
import { RouteContext } from '@/ui/router/RouteContext';
24+
import { InternalThemeProvider } from '@/ui/styledSystem';
25+
import { createEmotionCache } from '@/ui/styledSystem/createEmotionCache';
26+
import { extractCssLayerNameFromAppearance } from '@/ui/utils/extractCssLayerNameFromAppearance';
27+
28+
import { EnvironmentProvider } from '../contexts/EnvironmentContext';
29+
import { ModuleManagerProvider } from '../contexts/ModuleManagerContext';
30+
import { OptionsProvider } from '../contexts/OptionsContext';
31+
import { AppearanceOverrides } from '../elements/AppearanceOverrides';
32+
import { createComposedRouter } from './stubRouter';
33+
34+
// Used when `clerk.__internal_moduleManager` is `undefined`. In a correctly wired app clerk-js
35+
// exposes its ModuleManager through that getter, so reaching this means the loaded clerk-js is too
36+
// old to expose it (an older clerk-js also predates composed profiles entirely). Fail loudly on the
37+
// first dynamic import (Web3, billing, password strength) instead of silently resolving `undefined`
38+
// and surfacing later as an opaque access on the missing module.
39+
export const fallbackModuleManager: ModuleManager = {
40+
import: () =>
41+
Promise.reject(
42+
new ClerkRuntimeError(
43+
'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.',
44+
{ code: 'composed_module_manager_unavailable' },
45+
),
46+
),
47+
};
48+
49+
const composedOverrides: Elements = {
50+
profilePageContent: { padding: 0 },
51+
};
52+
53+
type ProfileProviderShellProps = PropsWithChildren<{
54+
clerk: LoadedClerk;
55+
environment: EnvironmentResource;
56+
moduleManager: ModuleManager;
57+
appearanceKey: 'userProfile' | 'organizationProfile';
58+
flow: 'userProfile' | 'organizationProfile';
59+
globalAppearance: Appearance | undefined;
60+
appearance?: Appearance;
61+
}>;
62+
63+
type SharedStyleCacheProviderProps = PropsWithChildren<{
64+
clerk: LoadedClerk;
65+
nonce?: string;
66+
cssLayerName?: string;
67+
}>;
68+
69+
// One emotion cache per clerk instance, so sibling composed roots share inserts.
70+
function SharedStyleCacheProvider({ clerk, nonce, cssLayerName, children }: SharedStyleCacheProviderProps): ReactNode {
71+
const cache = useMemo(() => {
72+
const existing = getStyleCache(clerk);
73+
if (existing) {
74+
return existing;
75+
}
76+
const next = createEmotionCache({ nonce, cssLayerName });
77+
setStyleCache(clerk, next);
78+
return next;
79+
}, [clerk, nonce, cssLayerName]);
80+
81+
return <CacheProvider value={cache}>{children}</CacheProvider>;
82+
}
83+
84+
export function ProfileProviderShell({
85+
children,
86+
clerk,
87+
environment,
88+
moduleManager,
89+
appearanceKey,
90+
flow,
91+
globalAppearance,
92+
appearance,
93+
}: ProfileProviderShellProps): ReactNode {
94+
// currentPath is left empty: composed has no Clerk-internal navigation. Each
95+
// section owns its own CardStateProvider, so errors clear on section unmount
96+
// (single-section mounting). Side-by-side sections keep independent error
97+
// state — a consumer URL change wouldn't be a meaningful signal to clear
98+
// either, so observing it would only cause spurious clears.
99+
const router = useMemo(() => createComposedRouter(clerk.navigate), [clerk]);
100+
// Match the portal path's normalization (Components.tsx:209) so a cssLayerName
101+
// nested inside appearance.theme gets hoisted to top-level for @layer wrapping.
102+
const normalizedGlobalAppearance = useMemo(
103+
() => extractCssLayerNameFromAppearance(globalAppearance),
104+
[globalAppearance],
105+
);
106+
const options = useMemo(
107+
() => ({
108+
localization: clerk.__internal_getOption('localization'),
109+
supportEmail: clerk.__internal_getOption('supportEmail'),
110+
}),
111+
[clerk],
112+
);
113+
114+
return (
115+
<SharedStyleCacheProvider
116+
clerk={clerk}
117+
// nonce lives on IsomorphicClerkOptions, not ClerkOptions, so the typed K-constraint rejects it
118+
nonce={(clerk as any).__internal_getOption('nonce')}
119+
cssLayerName={normalizedGlobalAppearance?.cssLayerName}
120+
>
121+
{/* parsed appearance for cl-* styled components */}
122+
<AppearanceProvider
123+
appearanceKey={appearanceKey}
124+
globalAppearance={normalizedGlobalAppearance}
125+
appearance={appearance}
126+
>
127+
{/* flow= for Flow.Root/Part data-clerk-* selectors */}
128+
<FlowMetadataProvider flow={flow}>
129+
{/* Emotion ThemeProvider over parsed theme */}
130+
<InternalThemeProvider>
131+
{/* dynamic-import bridge (Web3) */}
132+
<ModuleManagerProvider moduleManager={moduleManager}>
133+
{/* threads localization + supportEmail from the consumer's <ClerkProvider> */}
134+
<OptionsProvider value={options}>
135+
{/* read by useEnvironment() across MFA/account sections */}
136+
<EnvironmentProvider value={environment}>
137+
{/* router stub: navigate→clerk.navigate, matches/refresh no-op */}
138+
<RouteContext.Provider value={router}>
139+
{/* zero out profilePageContent padding when embedded */}
140+
<AppearanceOverrides elements={composedOverrides}>{children}</AppearanceOverrides>
141+
</RouteContext.Provider>
142+
</EnvironmentProvider>
143+
</OptionsProvider>
144+
</ModuleManagerProvider>
145+
</InternalThemeProvider>
146+
</FlowMetadataProvider>
147+
</AppearanceProvider>
148+
</SharedStyleCacheProvider>
149+
);
150+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { renderHook, act } from '@testing-library/react';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { createComposedRouter, stubRouter } from '../stubRouter';
5+
import { useBillingRouter } from '../useBillingRouter';
6+
7+
describe('createComposedRouter', () => {
8+
it('navigate delegates to clerkNavigate for same-origin paths', async () => {
9+
const clerkNavigate = vi.fn().mockResolvedValue(undefined);
10+
const router = createComposedRouter(clerkNavigate);
11+
12+
await router.navigate('/dashboard');
13+
14+
expect(clerkNavigate).toHaveBeenCalledWith('/dashboard');
15+
});
16+
17+
it('navigate delegates to clerkNavigate for relative paths', async () => {
18+
const clerkNavigate = vi.fn().mockResolvedValue(undefined);
19+
const router = createComposedRouter(clerkNavigate);
20+
21+
await router.navigate('../');
22+
23+
expect(clerkNavigate).toHaveBeenCalledWith('../');
24+
});
25+
26+
it('navigate delegates to clerkNavigate for external URLs', async () => {
27+
const clerkNavigate = vi.fn().mockResolvedValue(undefined);
28+
const router = createComposedRouter(clerkNavigate);
29+
30+
await router.navigate('https://external.example.com/callback');
31+
32+
expect(clerkNavigate).toHaveBeenCalledWith('https://external.example.com/callback');
33+
});
34+
35+
it('baseNavigate delegates to clerkNavigate with URL href', async () => {
36+
const clerkNavigate = vi.fn().mockResolvedValue(undefined);
37+
const router = createComposedRouter(clerkNavigate);
38+
39+
await router.baseNavigate(new URL('https://example.com/path'));
40+
41+
expect(clerkNavigate).toHaveBeenCalledWith('https://example.com/path');
42+
});
43+
44+
it('resolve produces URLs relative to current location', () => {
45+
const router = createComposedRouter(vi.fn());
46+
47+
const resolved = router.resolve('/some-path');
48+
expect(resolved.pathname).toBe('/some-path');
49+
});
50+
});
51+
52+
describe('createComposedRouter — AIO-only APIs throw in dev', () => {
53+
it('matches() throws', () => {
54+
const router = createComposedRouter(vi.fn());
55+
expect(() => router.matches('/foo')).toThrow(/not supported inside composed sections/);
56+
});
57+
58+
it('refresh() throws', () => {
59+
const router = createComposedRouter(vi.fn());
60+
expect(() => router.refresh()).toThrow(/not supported inside composed sections/);
61+
});
62+
63+
it('getMatchData() throws', () => {
64+
const router = createComposedRouter(vi.fn());
65+
expect(() => router.getMatchData('/foo')).toThrow(/not supported inside composed sections/);
66+
});
67+
});
68+
69+
describe('stubRouter fallback', () => {
70+
it('is created with window.location.assign as navigator', () => {
71+
// stubRouter is a pre-built instance that delegates to window.location.assign.
72+
// We can't spy on window.location.assign in jsdom, but we verify it's a valid router.
73+
expect(stubRouter.navigate).toBeDefined();
74+
expect(stubRouter.baseNavigate).toBeDefined();
75+
});
76+
});
77+
78+
describe('useBillingRouter — in-memory by design', () => {
79+
// Composed billing routing is purely React state — the consumer owns the
80+
// page URL. Trade-off: back/forward, refresh, and deep-links do not preserve
81+
// sub-route or tab state. These tests pin that decision.
82+
83+
let originalHash: string;
84+
85+
afterEach(() => {
86+
window.location.hash = originalHash ?? '';
87+
});
88+
89+
it('navigate() does not touch window.location.hash', async () => {
90+
originalHash = window.location.hash;
91+
const { result } = renderHook(() => useBillingRouter());
92+
93+
await act(async () => {
94+
await result.current.router.navigate('plans');
95+
});
96+
97+
expect(window.location.hash).toBe(originalHash);
98+
expect(result.current.route.page).toBe('plans');
99+
});
100+
101+
it('navigate() does not push a history entry', async () => {
102+
const before = window.history.length;
103+
const { result } = renderHook(() => useBillingRouter());
104+
105+
await act(async () => {
106+
await result.current.router.navigate('statement/abc');
107+
});
108+
109+
expect(window.history.length).toBe(before);
110+
});
111+
});
112+
113+
describe('createComposedRouter — SSR safety', () => {
114+
afterEach(() => {
115+
vi.unstubAllGlobals();
116+
});
117+
118+
it('resolve() does not throw when window is undefined', () => {
119+
vi.stubGlobal('window', undefined);
120+
121+
const router = createComposedRouter(vi.fn());
122+
expect(() => router.resolve('/some-path')).not.toThrow();
123+
expect(router.resolve('/some-path').pathname).toBe('/some-path');
124+
});
125+
126+
it('stubRouter.navigate is a no-op (does not throw) when window is undefined', async () => {
127+
vi.stubGlobal('window', undefined);
128+
129+
await expect(stubRouter.navigate('/foo')).resolves.toBeUndefined();
130+
});
131+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
'use client';
2+
3+
import type { ComponentType, ReactNode } from 'react';
4+
5+
import { useRequirePage } from './useRequirePage';
6+
7+
export function createSection(name: string, Component: ComponentType): () => ReactNode {
8+
function Section(): ReactNode {
9+
if (!useRequirePage(name)) return null;
10+
return <Component />;
11+
}
12+
Section.displayName = name;
13+
return Section;
14+
}

0 commit comments

Comments
 (0)