Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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