Skip to content
Open
25 changes: 25 additions & 0 deletions .changeset/fiery-peas-see.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@clerk/astro': minor
'@clerk/chrome-extension': minor
'@clerk/nextjs': minor
'@clerk/react': minor
'@clerk/shared': minor
'@clerk/ui': minor
'@clerk/vue': minor
---

Unify UI module configuration into a single `ui` prop on `ClerkProvider`. The prop accepts either:
- The `version` export from `@clerk/ui` for hot loading with version pinning
- The `ClerkUI` class constructor from `@clerk/ui` for direct module usage (bundled with your app)

```tsx
// Hot loading with version pinning
import { version } from '@clerk/ui';
<ClerkProvider ui={version} />

// Direct module usage (bundled with your app)
import { ClerkUI } from '@clerk/ui';
<ClerkProvider ui={ClerkUI} />
```

Only legitimate exports from `@clerk/ui` are accepted. The exports are branded with a symbol that is validated at runtime.
5 changes: 3 additions & 2 deletions integration/templates/express-vite/src/client/main.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Clerk } from '@clerk/clerk-js';
import { ClerkUi } from '@clerk/ui/entry';
import { ClerkUI } from '@clerk/ui';

const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;

document.addEventListener('DOMContentLoaded', async function () {
const clerk = new Clerk(publishableKey);

// Using clerkUiCtor internally to pass the constructor to clerk.load()
await clerk.load({
clerkUiCtor: ClerkUi,
clerkUiCtor: ClerkUI as any,
});

if (clerk.isSignedIn) {
Expand Down
50 changes: 47 additions & 3 deletions packages/astro/src/internal/create-clerk-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,18 +108,62 @@ async function getClerkJsEntryChunk<TUi extends Ui = Ui>(options?: AstroClerkCre
await loadClerkJsScript(options);
}

/**
* The well-known symbol used to identify legitimate @clerk/ui exports.
* Uses Symbol.for() to ensure the same symbol is used across module boundaries.
*/
const UI_BRAND_SYMBOL = Symbol.for('clerk:ui');

/**
* Checks if the provided ui option is a legitimate @clerk/ui export
*/
function isLegitimateUiExport(ui: unknown): boolean {
if (!ui || (typeof ui !== 'object' && typeof ui !== 'function')) {
return false;
}
return (ui as any).__brand === UI_BRAND_SYMBOL;
}

/**
* Checks if the provided ui option is a ClerkUi constructor (class)
* rather than a version pinning object
*/
function isUiConstructor(ui: unknown): ui is ClerkUiConstructor {
return typeof ui === 'function' && isLegitimateUiExport(ui);
}

/**
* Checks if the provided ui option is a version object for hot loading
*/
function isUiVersion(ui: unknown): ui is { version: string; url?: string } {
return typeof ui === 'object' && ui !== null && isLegitimateUiExport(ui) && 'version' in ui;
}

/**
* Gets the ClerkUI constructor, either from options or by loading the script.
* Returns early if window.__internal_ClerkUiCtor already exists.
*/
async function getClerkUiEntryChunk<TUi extends Ui = Ui>(
options?: AstroClerkCreateInstanceParams<TUi>,
): Promise<ClerkUiConstructor> {
if (options?.clerkUiCtor) {
return options.clerkUiCtor;
// If ui is a constructor (ClerkUI class), use it directly
if (isUiConstructor(options?.ui)) {
return options.ui;
}

await loadClerkUiScript(options);
// Get version info if it's a legitimate version object
const uiVersion = isUiVersion(options?.ui) ? options.ui : undefined;

await loadClerkUiScript(
options
? {
...options,
clerkUiVersion: uiVersion?.version,
// Only override clerkUiUrl if uiVersion provides a url, otherwise keep existing
clerkUiUrl: uiVersion?.url || options.clerkUiUrl,
}
: undefined,
);

if (!window.__internal_ClerkUiCtor) {
throw new Error('Failed to download latest Clerk UI. Contact [email protected].');
Expand Down
8 changes: 8 additions & 0 deletions packages/astro/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ type AstroClerkIntegrationParams<TUi extends Ui = Ui> = Without<
* The URL that `@clerk/ui` should be hot-loaded from.
*/
clerkUiUrl?: string;
/**
* The UI module configuration. Accepts either:
* - The `version` export from `@clerk/ui` for hot loading with version pinning
* - The `ClerkUI` class constructor from `@clerk/ui` for direct module usage
*
* Note: Only legitimate exports from `@clerk/ui` are accepted (validated via symbol).
*/
ui?: TUi;
};

type AstroClerkCreateInstanceParams<TUi extends Ui = Ui> = AstroClerkIntegrationParams<TUi> & {
Expand Down
11 changes: 6 additions & 5 deletions packages/chrome-extension/src/react/ClerkProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { Clerk } from '@clerk/clerk-js/no-rhc';
import type { ClerkProviderProps as ClerkReactProviderProps } from '@clerk/react';
import { ClerkProvider as ClerkReactProvider } from '@clerk/react';
import type { Ui } from '@clerk/react/internal';
import { ClerkUi } from '@clerk/ui/entry';
import { ClerkUI } from '@clerk/ui';
import type { Appearance, UiModule } from '@clerk/ui/internal';
import React from 'react';

import { createClerkClient } from '../internal/clerk';
import type { StorageCache } from '../internal/utils/storage';

type ChromeExtensionClerkProviderProps<TUi extends Ui = Ui> = ClerkReactProviderProps<TUi> & {
// Chrome extension always bundles @clerk/ui, so we use the specific UiModule type
type ChromeExtensionClerkProviderProps = Omit<ClerkReactProviderProps<UiModule<Appearance>>, 'ui'> & {
/**
* @experimental
* @description Enables the listener to sync host cookies on changes.
Expand All @@ -18,7 +19,7 @@ type ChromeExtensionClerkProviderProps<TUi extends Ui = Ui> = ClerkReactProvider
syncHost?: string;
};

export function ClerkProvider<TUi extends Ui = Ui>(props: ChromeExtensionClerkProviderProps<TUi>): JSX.Element | null {
export function ClerkProvider(props: ChromeExtensionClerkProviderProps): JSX.Element | null {
const { children, storageCache, syncHost, __experimental_syncHostListener, ...rest } = props;
const { publishableKey = '' } = props;

Expand All @@ -36,7 +37,7 @@ export function ClerkProvider<TUi extends Ui = Ui>(props: ChromeExtensionClerkPr
<ClerkReactProvider
{...rest}
Clerk={clerkInstance}
clerkUiCtor={ClerkUi}
ui={ClerkUI}
standardBrowser={!syncHost}
>
{children}
Expand Down
5 changes: 4 additions & 1 deletion packages/nextjs/src/utils/clerk-script.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export function ClerkScripts({ router }: { router: ClerkScriptProps['router'] })
return null;
}

// Only version objects have url property, constructors don't
const uiVersionUrl = ui && typeof ui === 'object' && 'url' in ui ? ui.url : undefined;

const opts = {
publishableKey,
clerkJSUrl,
Expand All @@ -59,7 +62,7 @@ export function ClerkScripts({ router }: { router: ClerkScriptProps['router'] })
domain,
proxyUrl,
clerkUiVersion: ui?.version,
clerkUiUrl: ui?.url || clerkUiUrl,
clerkUiUrl: uiVersionUrl || clerkUiUrl,
};

return (
Expand Down
43 changes: 39 additions & 4 deletions packages/react/src/isomorphicClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,15 +508,50 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
return global.Clerk;
}

/**
* The well-known symbol used to identify legitimate @clerk/ui exports.
* Uses Symbol.for() to ensure the same symbol is used across module boundaries.
*/
private static readonly UI_BRAND_SYMBOL = Symbol.for('clerk:ui');

/**
* Checks if the provided ui option is a legitimate @clerk/ui export
*/
private isLegitimateUiExport(ui: unknown): boolean {
if (!ui || (typeof ui !== 'object' && typeof ui !== 'function')) {
return false;
}
return (ui as any).__brand === IsomorphicClerk.UI_BRAND_SYMBOL;
}

/**
* Checks if the provided ui option is a ClerkUi constructor (class)
* rather than a version pinning object
*/
private isUiConstructor(ui: unknown): ui is ClerkUiConstructor {
return typeof ui === 'function' && this.isLegitimateUiExport(ui);
}

/**
* Checks if the provided ui option is a version object for hot loading
*/
private isUiVersion(ui: unknown): ui is { version: string; url?: string } {
return typeof ui === 'object' && ui !== null && this.isLegitimateUiExport(ui) && 'version' in ui;
}

private async getClerkUiEntryChunk(): Promise<ClerkUiConstructor> {
if (this.options.clerkUiCtor) {
return this.options.clerkUiCtor;
// If ui is a constructor (ClerkUI class), use it directly
if (this.isUiConstructor(this.options.ui)) {
return this.options.ui;
}

// Otherwise, hot load the UI script based on version/url
const uiVersion = this.isUiVersion(this.options.ui) ? this.options.ui : undefined;

await loadClerkUiScript({
...this.options,
clerkUiVersion: this.options.ui?.version,
clerkUiUrl: this.options.ui?.url || this.options.clerkUiUrl,
clerkUiVersion: uiVersion?.version,
clerkUiUrl: uiVersion?.url || this.options.clerkUiUrl,
publishableKey: this.#publishableKey,
proxyUrl: this.proxyUrl,
domain: this.domain,
Expand Down
22 changes: 19 additions & 3 deletions packages/react/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,25 @@ export type ClerkProviderProps<TUi extends Ui = Ui> = Omit<IsomorphicClerkOption
*/
appearance?: ExtractAppearanceType<TUi, Appearance>;
/**
* Optional object to pin the UI version your app will be using. Useful when you've extensively customize the look and feel of the
* components using the appearance prop.
* Note: When `ui` is used, appearance is automatically typed based on the specific UI version.
* Optional prop to configure the UI module. Accepts either:
* - The `version` export from `@clerk/ui` for hot loading with version pinning
* - The `ClerkUI` class constructor from `@clerk/ui` for direct module usage
*
* When using version pinning, the UI is hot-loaded from the CDN.
* When using the ClerkUI constructor directly, the UI is bundled with your app.
*
* Note: Only legitimate exports from `@clerk/ui` are accepted. Arbitrary objects
* or strings will be ignored.
*
* @example
* // Hot loading with version pinning
* import { version } from '@clerk/ui';
* <ClerkProvider ui={version} />
*
* @example
* // Direct module usage (bundled with your app)
* import { ClerkUI } from '@clerk/ui';
* <ClerkProvider ui={ClerkUI} />
*/
ui?: TUi;
};
Expand Down
26 changes: 21 additions & 5 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1083,7 +1083,10 @@ export type ClerkOptions = ClerkOptionsNavigation &
AfterMultiSessionSingleSignOutUrl &
ClerkUnsafeOptions & {
/**
* Clerk UI entrypoint.
* @internal
* Clerk UI constructor. Used internally to pass the resolved UI constructor to clerk.load().
* For public usage, prefer the `ui` prop on ClerkProvider which accepts both version objects
* and the ClerkUI constructor.
*/
clerkUiCtor?: ClerkUiConstructor | Promise<ClerkUiConstructor>;
/**
Expand Down Expand Up @@ -2370,11 +2373,24 @@ export type IsomorphicClerkOptions = Without<ClerkOptions, 'isSatellite'> & {
*/
nonce?: string;
/**
* @internal
* This is a structural-only type for the `ui` object that can be passed
* to Clerk.load() and ClerkProvider
* The UI module configuration. Accepts either:
* - The `version` export from `@clerk/ui` for hot loading with version pinning
* - The `ClerkUI` class constructor from `@clerk/ui` for direct module usage
*
* Note: Only legitimate exports from `@clerk/ui` are accepted (validated via symbol).
* Arbitrary objects or strings will be ignored.
*
* @example
* // Hot loading with version pinning
* import { version } from '@clerk/ui';
* <ClerkProvider ui={version} />
*
* @example
* // Direct module usage (bundled with your app)
* import { ClerkUI } from '@clerk/ui';
* <ClerkProvider ui={ClerkUI} />
*/
ui?: { version: string; url?: string };
ui?: { __brand: symbol; version: string; url?: string } | ClerkUiConstructor;
} & MultiDomainAndOrProxy;

export interface LoadedClerk extends Clerk {
Expand Down
35 changes: 30 additions & 5 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,37 @@
import type { Ui } from './internal';
import { ClerkUi as ClerkUiClass } from './ClerkUi';
import type { UiModule, UiVersion } from './internal';
import type { Appearance } from './internal/appearance';

declare const PACKAGE_VERSION: string;

/**
* Default ui object for Clerk UI components
* Tagged with the internal Appearance type for type-safe appearance prop inference
* Symbol used to identify legitimate @clerk/ui exports at runtime.
* This prevents arbitrary objects from being passed to the ui prop.
* @internal
*/
export const ui = {
export const UI_BRAND_SYMBOL = Symbol.for('clerk:ui');

/**
* Version object for Clerk UI components.
* Use this for version pinning with hot loading from CDN.
*
* @example
* import { version } from '@clerk/ui';
* <ClerkProvider ui={version} />
*/
export const version = {
__brand: UI_BRAND_SYMBOL,
version: PACKAGE_VERSION,
} as Ui<Appearance>;
} as UiVersion<Appearance>;

/**
* ClerkUI class constructor for direct module usage.
* Use this when you want to bundle @clerk/ui directly instead of hot loading.
*
* @example
* import { ClerkUI } from '@clerk/ui';
* <ClerkProvider ui={ClerkUI} />
*/
// Add the brand symbol to the class
(ClerkUiClass as any).__brand = UI_BRAND_SYMBOL;
export const ClerkUI = ClerkUiClass as unknown as UiModule<Appearance>;
Loading
Loading