From 5b182879fea4a1835210ddecd759a293b03f52e4 Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 9 Jan 2026 16:57:52 -0600 Subject: [PATCH 1/8] feat(ui): Unify ui prop to accept both version object and ClerkUI constructor This change consolidates the UI module configuration into a single `ui` prop that accepts either: - A version object (`ui` export from @clerk/ui) for hot loading with version pinning - The ClerkUI class constructor (`ClerkUI` export from @clerk/ui) for direct module usage Changes: - Split Ui type into UiVersion and UiModule, with Ui as a union of both - Export ClerkUI constructor from @clerk/ui for direct bundling usage - Update IsomorphicClerk to detect and handle both ui types - Update IsomorphicClerkOptions.ui to accept both types - Keep clerkUiCtor as internal property for clerk.load() compatibility - Update @clerk/astro, @clerk/vue, @clerk/chrome-extension to use new pattern --- .../templates/express-vite/src/client/main.ts | 5 ++- .../src/internal/create-clerk-instance.ts | 13 ++++++- packages/astro/src/types.ts | 6 +++ .../src/react/ClerkProvider.tsx | 12 +++--- packages/react/src/isomorphicClerk.ts | 20 ++++++++-- packages/react/src/types.ts | 19 ++++++++-- packages/shared/src/types/clerk.ts | 23 ++++++++--- packages/ui/src/index.ts | 14 ++++++- packages/ui/src/internal/index.ts | 38 +++++++++++++++++-- packages/vue/src/plugin.ts | 14 ++++++- 10 files changed, 136 insertions(+), 28 deletions(-) diff --git a/integration/templates/express-vite/src/client/main.ts b/integration/templates/express-vite/src/client/main.ts index bf19f46d7b7..3ab7bb3f96e 100644 --- a/integration/templates/express-vite/src/client/main.ts +++ b/integration/templates/express-vite/src/client/main.ts @@ -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) { diff --git a/packages/astro/src/internal/create-clerk-instance.ts b/packages/astro/src/internal/create-clerk-instance.ts index e1cbd520144..3e1bb429ecc 100644 --- a/packages/astro/src/internal/create-clerk-instance.ts +++ b/packages/astro/src/internal/create-clerk-instance.ts @@ -108,6 +108,14 @@ async function getClerkJsEntryChunk(options?: AstroClerkCre await loadClerkJsScript(options); } +/** + * 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' && 'version' in ui; +} + /** * Gets the ClerkUI constructor, either from options or by loading the script. * Returns early if window.__internal_ClerkUiCtor already exists. @@ -115,8 +123,9 @@ async function getClerkJsEntryChunk(options?: AstroClerkCre async function getClerkUiEntryChunk( options?: AstroClerkCreateInstanceParams, ): Promise { - 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); diff --git a/packages/astro/src/types.ts b/packages/astro/src/types.ts index 7f0613e5968..63d4bf7efbc 100644 --- a/packages/astro/src/types.ts +++ b/packages/astro/src/types.ts @@ -36,6 +36,12 @@ type AstroClerkIntegrationParams = Without< * The URL that `@clerk/ui` should be hot-loaded from. */ clerkUiUrl?: string; + /** + * The UI module configuration. Accepts either: + * - A version object for hot loading with version pinning + * - The ClerkUI class constructor for direct module usage + */ + ui?: TUi; }; type AstroClerkCreateInstanceParams = AstroClerkIntegrationParams & { diff --git a/packages/chrome-extension/src/react/ClerkProvider.tsx b/packages/chrome-extension/src/react/ClerkProvider.tsx index 45237484496..0973dcd6f1b 100644 --- a/packages/chrome-extension/src/react/ClerkProvider.tsx +++ b/packages/chrome-extension/src/react/ClerkProvider.tsx @@ -1,14 +1,16 @@ 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 type { UiModule } from '@clerk/ui/internal'; +import { ClerkUI } from '@clerk/ui'; +import type { Appearance } from '@clerk/ui/internal'; import React from 'react'; import { createClerkClient } from '../internal/clerk'; import type { StorageCache } from '../internal/utils/storage'; -type ChromeExtensionClerkProviderProps = ClerkReactProviderProps & { +// Chrome extension always bundles @clerk/ui, so we use the specific UiModule type +type ChromeExtensionClerkProviderProps = Omit>, 'ui'> & { /** * @experimental * @description Enables the listener to sync host cookies on changes. @@ -18,7 +20,7 @@ type ChromeExtensionClerkProviderProps = ClerkReactProvider syncHost?: string; }; -export function ClerkProvider(props: ChromeExtensionClerkProviderProps): JSX.Element | null { +export function ClerkProvider(props: ChromeExtensionClerkProviderProps): JSX.Element | null { const { children, storageCache, syncHost, __experimental_syncHostListener, ...rest } = props; const { publishableKey = '' } = props; @@ -36,7 +38,7 @@ export function ClerkProvider(props: ChromeExtensionClerkPr {children} diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index b39bf352b8c..f7c1071a704 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -508,15 +508,27 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { return global.Clerk; } + /** + * 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' && 'version' in ui; + } + private async getClerkUiEntryChunk(): Promise { - 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 = typeof this.options.ui === 'object' ? 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, diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index c769d58afac..a517fc25088 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -53,9 +53,22 @@ export type ClerkProviderProps = Omit; /** - * 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: + * - A version object (e.g., `ui` export from `@clerk/ui`) for hot loading with version pinning + * - The ClerkUI class constructor (e.g., `ClerkUI` export 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. + * + * @example + * // Hot loading with version pinning + * import { ui } from '@clerk/ui'; + * + * + * @example + * // Direct module usage (bundled with your app) + * import { ClerkUI } from '@clerk/ui'; + * */ ui?: TUi; }; diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 33b15d3bfe8..cd4b86dc707 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -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; /** @@ -2370,11 +2373,21 @@ export type IsomorphicClerkOptions = Without & { */ 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: + * - A version object `{ version: string; url?: string }` for hot loading with version pinning + * - The ClerkUI class constructor for direct module usage (bypasses hot loading) + * + * @example + * // Hot loading with version pinning + * import { ui } from '@clerk/ui'; + * + * + * @example + * // Direct module usage (bundled with your app) + * import { ClerkUI } from '@clerk/ui'; + * */ - ui?: { version: string; url?: string }; + ui?: { version: string; url?: string } | ClerkUiConstructor; } & MultiDomainAndOrProxy; export interface LoadedClerk extends Clerk { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index cc3aa52b41b..76263fd10e1 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,12 +1,22 @@ -import type { Ui } from './internal'; +import type { UiModule, UiVersion } from './internal'; import type { Appearance } from './internal/appearance'; +import { ClerkUi as ClerkUiClass } from './ClerkUi'; + declare const PACKAGE_VERSION: string; /** * Default ui object for Clerk UI components * Tagged with the internal Appearance type for type-safe appearance prop inference + * Used for version pinning with hot loading */ export const ui = { version: PACKAGE_VERSION, -} as Ui; +} as UiVersion; + +/** + * ClerkUI class constructor for direct module usage + * Use this when you want to bundle @clerk/ui directly instead of hot loading + * Tagged with the internal Appearance type for type-safe appearance prop inference + */ +export const ClerkUI = ClerkUiClass as unknown as UiModule; diff --git a/packages/ui/src/internal/index.ts b/packages/ui/src/internal/index.ts index 2a9e39b207e..a3e9548e362 100644 --- a/packages/ui/src/internal/index.ts +++ b/packages/ui/src/internal/index.ts @@ -19,10 +19,11 @@ declare const Tags: unique symbol; type Tagged = BaseType & { [Tags]: { [K in Tag]: void } }; /** - * Ui type that carries appearance type information via phantom property + * UiVersion type that carries appearance type information via phantom property * Tagged to ensure only official ui objects from @clerk/ui can be used + * Used for version pinning with hot loading */ -export type Ui = Tagged< +export type UiVersion = Tagged< { version: string; url?: string; @@ -35,6 +36,37 @@ export type Ui = Tagged< 'ClerkUi' >; +/** + * UiModule type represents the ClerkUi class constructor + * Used when bundling @clerk/ui directly instead of hot loading + * Tagged to ensure only official ClerkUi class from @clerk/ui can be used + */ +export type UiModule = Tagged< + { + /** + * The version string of the UI module + */ + version: string; + /** + * Constructor signature - must be callable with new + */ + new (...args: any[]): any; + /** + * Phantom property for type-level appearance inference + * This property never exists at runtime + */ + __appearanceType?: A; + }, + 'ClerkUiModule' +>; + +/** + * Ui type that accepts either: + * - UiVersion: version pinning object for hot loading + * - UiModule: ClerkUi class constructor for direct module usage + */ +export type Ui = UiVersion | UiModule; + export type { AlphaColorScale, Appearance, @@ -94,4 +126,4 @@ export type { export const localUiForTesting = { version: PACKAGE_VERSION, url: 'http://localhost:4011/npm/ui.browser.js', -} as Ui; +} as UiVersion; diff --git a/packages/vue/src/plugin.ts b/packages/vue/src/plugin.ts index 91a89ffe91d..f6130e46542 100644 --- a/packages/vue/src/plugin.ts +++ b/packages/vue/src/plugin.ts @@ -72,14 +72,24 @@ export const clerkPlugin: Plugin<[PluginOptions]> = { sdkMetadata: pluginOptions.sdkMetadata || SDK_METADATA, } as LoadClerkJsScriptOptions; + /** + * Checks if the provided ui option is a ClerkUi constructor (class) + * rather than a version pinning object + */ + const isUiConstructor = (ui: unknown): ui is ClerkUiConstructor => { + return typeof ui === 'function' && 'version' in ui; + }; + // We need this check for SSR apps like Nuxt as it will try to run this code on the server // and loadClerkJsScript contains browser-specific code if (inBrowser()) { void (async () => { try { const clerkPromise = loadClerkJsScript(options); - const clerkUiCtorPromise = pluginOptions.clerkUiCtor - ? Promise.resolve(pluginOptions.clerkUiCtor) + // If ui is a constructor (ClerkUI class), use it directly + // Otherwise, hot load the UI script + const clerkUiCtorPromise = isUiConstructor(pluginOptions.ui) + ? Promise.resolve(pluginOptions.ui) : (async () => { await loadClerkUiScript(options); if (!window.__internal_ClerkUiCtor) { From cc701e478b6556f35106708ffd3be074001a760c Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 9 Jan 2026 17:05:00 -0600 Subject: [PATCH 2/8] fix(ui): Rename ui export to version and add symbol-based validation - Rename `ui` export to `version` in @clerk/ui for clarity - Add `UI_BRAND_SYMBOL` to validate legitimate @clerk/ui exports at runtime - Update detection logic in react, astro, vue to check for brand symbol - Only accept exports with the correct symbol, reject arbitrary objects --- .../src/internal/create-clerk-instance.ts | 38 ++++++++++++++++++- packages/astro/src/types.ts | 6 ++- packages/react/src/isomorphicClerk.ts | 27 ++++++++++++- packages/react/src/types.ts | 11 ++++-- packages/shared/src/types/clerk.ts | 13 ++++--- packages/ui/src/index.ts | 30 +++++++++++---- packages/ui/src/internal/index.ts | 16 ++++++++ packages/vue/src/plugin.ts | 32 +++++++++++++++- 8 files changed, 149 insertions(+), 24 deletions(-) diff --git a/packages/astro/src/internal/create-clerk-instance.ts b/packages/astro/src/internal/create-clerk-instance.ts index 3e1bb429ecc..7c17ef9bac4 100644 --- a/packages/astro/src/internal/create-clerk-instance.ts +++ b/packages/astro/src/internal/create-clerk-instance.ts @@ -108,12 +108,35 @@ async function getClerkJsEntryChunk(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' && 'version' in ui; + 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; } /** @@ -128,7 +151,18 @@ async function getClerkUiEntryChunk( 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, + clerkUiUrl: uiVersion?.url, + } + : undefined, + ); if (!window.__internal_ClerkUiCtor) { throw new Error('Failed to download latest Clerk UI. Contact support@clerk.com.'); diff --git a/packages/astro/src/types.ts b/packages/astro/src/types.ts index 63d4bf7efbc..d4012666add 100644 --- a/packages/astro/src/types.ts +++ b/packages/astro/src/types.ts @@ -38,8 +38,10 @@ type AstroClerkIntegrationParams = Without< clerkUiUrl?: string; /** * The UI module configuration. Accepts either: - * - A version object for hot loading with version pinning - * - The ClerkUI class constructor for direct module usage + * - 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; }; diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index f7c1071a704..2acc37a7d24 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -508,12 +508,35 @@ 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' && 'version' in ui; + 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 { @@ -523,7 +546,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } // Otherwise, hot load the UI script based on version/url - const uiVersion = typeof this.options.ui === 'object' ? this.options.ui : undefined; + const uiVersion = this.isUiVersion(this.options.ui) ? this.options.ui : undefined; await loadClerkUiScript({ ...this.options, diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index a517fc25088..0612a16ec44 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -54,16 +54,19 @@ export type ClerkProviderProps = Omit; /** * Optional prop to configure the UI module. Accepts either: - * - A version object (e.g., `ui` export from `@clerk/ui`) for hot loading with version pinning - * - The ClerkUI class constructor (e.g., `ClerkUI` export from `@clerk/ui`) for direct module usage + * - 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 { ui } from '@clerk/ui'; - * + * import { version } from '@clerk/ui'; + * * * @example * // Direct module usage (bundled with your app) diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index cd4b86dc707..1444928bfc3 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -2374,20 +2374,23 @@ export type IsomorphicClerkOptions = Without & { nonce?: string; /** * The UI module configuration. Accepts either: - * - A version object `{ version: string; url?: string }` for hot loading with version pinning - * - The ClerkUI class constructor for direct module usage (bypasses hot loading) + * - 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 { ui } from '@clerk/ui'; - * + * import { version } from '@clerk/ui'; + * * * @example * // Direct module usage (bundled with your app) * import { ClerkUI } from '@clerk/ui'; * */ - ui?: { version: string; url?: string } | ClerkUiConstructor; + ui?: { __brand: symbol; version: string; url?: string } | ClerkUiConstructor; } & MultiDomainAndOrProxy; export interface LoadedClerk extends Clerk { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 76263fd10e1..bc311099bf6 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -6,17 +6,33 @@ import { ClerkUi as ClerkUiClass } from './ClerkUi'; declare const PACKAGE_VERSION: string; /** - * Default ui object for Clerk UI components - * Tagged with the internal Appearance type for type-safe appearance prop inference - * Used for version pinning with hot loading + * 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'; + * + */ +export const version = { + __brand: UI_BRAND_SYMBOL, version: PACKAGE_VERSION, } as UiVersion; /** - * ClerkUI class constructor for direct module usage - * Use this when you want to bundle @clerk/ui directly instead of hot loading - * Tagged with the internal Appearance type for type-safe appearance prop inference + * 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'; + * */ +// Add the brand symbol to the class +(ClerkUiClass as any).__brand = UI_BRAND_SYMBOL; export const ClerkUI = ClerkUiClass as unknown as UiModule; diff --git a/packages/ui/src/internal/index.ts b/packages/ui/src/internal/index.ts index a3e9548e362..0877f0eaf02 100644 --- a/packages/ui/src/internal/index.ts +++ b/packages/ui/src/internal/index.ts @@ -18,6 +18,13 @@ export type ExtractAppearanceType = T extends { __appearanceType?: i declare const Tags: unique symbol; type Tagged = BaseType & { [Tags]: { [K in Tag]: void } }; +/** + * The well-known symbol key used to identify legitimate @clerk/ui exports. + * Uses Symbol.for() to ensure the same symbol is used across module boundaries. + * @internal + */ +export const UI_BRAND_SYMBOL_KEY = 'clerk:ui'; + /** * UiVersion type that carries appearance type information via phantom property * Tagged to ensure only official ui objects from @clerk/ui can be used @@ -25,6 +32,10 @@ type Tagged = BaseType & { [Tags]: { [K in Ta */ export type UiVersion = Tagged< { + /** + * Brand symbol to identify legitimate @clerk/ui exports at runtime + */ + __brand: symbol; version: string; url?: string; /** @@ -43,6 +54,10 @@ export type UiVersion = Tagged< */ export type UiModule = Tagged< { + /** + * Brand symbol to identify legitimate @clerk/ui exports at runtime + */ + __brand: symbol; /** * The version string of the UI module */ @@ -124,6 +139,7 @@ export type { * Do not use */ export const localUiForTesting = { + __brand: Symbol.for(UI_BRAND_SYMBOL_KEY), version: PACKAGE_VERSION, url: 'http://localhost:4011/npm/ui.browser.js', } as UiVersion; diff --git a/packages/vue/src/plugin.ts b/packages/vue/src/plugin.ts index f6130e46542..e4c6ad34ea6 100644 --- a/packages/vue/src/plugin.ts +++ b/packages/vue/src/plugin.ts @@ -72,12 +72,35 @@ export const clerkPlugin: Plugin<[PluginOptions]> = { sdkMetadata: pluginOptions.sdkMetadata || SDK_METADATA, } as LoadClerkJsScriptOptions; + /** + * 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 + */ + const 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 */ const isUiConstructor = (ui: unknown): ui is ClerkUiConstructor => { - return typeof ui === 'function' && 'version' in ui; + return typeof ui === 'function' && isLegitimateUiExport(ui); + }; + + /** + * Checks if the provided ui option is a version object for hot loading + */ + const isUiVersion = (ui: unknown): ui is { version: string; url?: string } => { + return typeof ui === 'object' && ui !== null && isLegitimateUiExport(ui) && 'version' in ui; }; // We need this check for SSR apps like Nuxt as it will try to run this code on the server @@ -88,10 +111,15 @@ export const clerkPlugin: Plugin<[PluginOptions]> = { const clerkPromise = loadClerkJsScript(options); // If ui is a constructor (ClerkUI class), use it directly // Otherwise, hot load the UI script + const uiVersion = isUiVersion(pluginOptions.ui) ? pluginOptions.ui : undefined; const clerkUiCtorPromise = isUiConstructor(pluginOptions.ui) ? Promise.resolve(pluginOptions.ui) : (async () => { - await loadClerkUiScript(options); + await loadClerkUiScript({ + ...options, + clerkUiVersion: uiVersion?.version, + clerkUiUrl: uiVersion?.url, + }); if (!window.__internal_ClerkUiCtor) { throw new Error('Failed to download latest Clerk UI. Contact support@clerk.com.'); } From 894ab035013afd69ca11dad575803002d437df47 Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 9 Jan 2026 19:46:37 -0600 Subject: [PATCH 3/8] fix(ui): Simplify Ui types and fix url access on union type - Remove Tagged wrapper from UiVersion and UiModule types since symbol brand already provides sufficient runtime validation - Fix clerk-script.tsx to properly access url property on version objects by checking type at runtime --- packages/nextjs/src/utils/clerk-script.tsx | 5 +- packages/ui/src/internal/index.ts | 75 +++++++++------------- 2 files changed, 36 insertions(+), 44 deletions(-) diff --git a/packages/nextjs/src/utils/clerk-script.tsx b/packages/nextjs/src/utils/clerk-script.tsx index aceb76c4d9d..d6c3da2d4dd 100644 --- a/packages/nextjs/src/utils/clerk-script.tsx +++ b/packages/nextjs/src/utils/clerk-script.tsx @@ -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, @@ -59,7 +62,7 @@ export function ClerkScripts({ router }: { router: ClerkScriptProps['router'] }) domain, proxyUrl, clerkUiVersion: ui?.version, - clerkUiUrl: ui?.url || clerkUiUrl, + clerkUiUrl: uiVersionUrl || clerkUiUrl, }; return ( diff --git a/packages/ui/src/internal/index.ts b/packages/ui/src/internal/index.ts index 0877f0eaf02..a47d5eb331f 100644 --- a/packages/ui/src/internal/index.ts +++ b/packages/ui/src/internal/index.ts @@ -15,9 +15,6 @@ export type ExtractAppearanceType = T extends { __appearanceType?: i : A : Default; -declare const Tags: unique symbol; -type Tagged = BaseType & { [Tags]: { [K in Tag]: void } }; - /** * The well-known symbol key used to identify legitimate @clerk/ui exports. * Uses Symbol.for() to ensure the same symbol is used across module boundaries. @@ -27,53 +24,45 @@ export const UI_BRAND_SYMBOL_KEY = 'clerk:ui'; /** * UiVersion type that carries appearance type information via phantom property - * Tagged to ensure only official ui objects from @clerk/ui can be used * Used for version pinning with hot loading */ -export type UiVersion = Tagged< - { - /** - * Brand symbol to identify legitimate @clerk/ui exports at runtime - */ - __brand: symbol; - version: string; - url?: string; - /** - * Phantom property for type-level appearance inference - * This property never exists at runtime - */ - __appearanceType?: A; - }, - 'ClerkUi' ->; +export type UiVersion = { + /** + * Brand symbol to identify legitimate @clerk/ui exports at runtime + */ + __brand: symbol; + version: string; + url?: string; + /** + * Phantom property for type-level appearance inference + * This property never exists at runtime + */ + __appearanceType?: A; +}; /** * UiModule type represents the ClerkUi class constructor * Used when bundling @clerk/ui directly instead of hot loading - * Tagged to ensure only official ClerkUi class from @clerk/ui can be used */ -export type UiModule = Tagged< - { - /** - * Brand symbol to identify legitimate @clerk/ui exports at runtime - */ - __brand: symbol; - /** - * The version string of the UI module - */ - version: string; - /** - * Constructor signature - must be callable with new - */ - new (...args: any[]): any; - /** - * Phantom property for type-level appearance inference - * This property never exists at runtime - */ - __appearanceType?: A; - }, - 'ClerkUiModule' ->; +export type UiModule = { + /** + * Brand symbol to identify legitimate @clerk/ui exports at runtime + */ + __brand: symbol; + /** + * The version string of the UI module + */ + version: string; + /** + * Constructor signature - must be callable with new + */ + new (...args: any[]): any; + /** + * Phantom property for type-level appearance inference + * This property never exists at runtime + */ + __appearanceType?: A; +}; /** * Ui type that accepts either: From 76c5d87484b6fe1f25fe8b7d2b352edc53dc7d31 Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 9 Jan 2026 19:49:03 -0600 Subject: [PATCH 4/8] chore: add changeset --- .changeset/fiery-peas-see.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .changeset/fiery-peas-see.md diff --git a/.changeset/fiery-peas-see.md b/.changeset/fiery-peas-see.md new file mode 100644 index 00000000000..cf286537142 --- /dev/null +++ b/.changeset/fiery-peas-see.md @@ -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'; + + +// Direct module usage (bundled with your app) +import { ClerkUI } from '@clerk/ui'; + +``` + +Only legitimate exports from `@clerk/ui` are accepted. The exports are branded with a symbol that is validated at runtime. From 6a713119bfa11a07de6c17cfc88eeeb3fce39c0e Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 9 Jan 2026 19:55:13 -0600 Subject: [PATCH 5/8] fix: sort imports --- packages/chrome-extension/src/react/ClerkProvider.tsx | 3 +-- packages/ui/src/index.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/chrome-extension/src/react/ClerkProvider.tsx b/packages/chrome-extension/src/react/ClerkProvider.tsx index 0973dcd6f1b..8c676092448 100644 --- a/packages/chrome-extension/src/react/ClerkProvider.tsx +++ b/packages/chrome-extension/src/react/ClerkProvider.tsx @@ -1,9 +1,8 @@ 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 { UiModule } from '@clerk/ui/internal'; import { ClerkUI } from '@clerk/ui'; -import type { Appearance } from '@clerk/ui/internal'; +import type { Appearance, UiModule } from '@clerk/ui/internal'; import React from 'react'; import { createClerkClient } from '../internal/clerk'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index bc311099bf6..b63b17e384a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,8 +1,7 @@ +import { ClerkUi as ClerkUiClass } from './ClerkUi'; import type { UiModule, UiVersion } from './internal'; import type { Appearance } from './internal/appearance'; -import { ClerkUi as ClerkUiClass } from './ClerkUi'; - declare const PACKAGE_VERSION: string; /** From c1db0bb746093ec8bb636de54b39d802ae65f5dd Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 9 Jan 2026 20:57:22 -0600 Subject: [PATCH 6/8] fix(ui): Use unknown instead of any for UiModule and UiVersion generics - Change UiModule constructor args from any[] to unknown[] - Add instance type generic parameter I to UiModule (defaults to unknown) - Change appearance type defaults from any to unknown for type safety - Add @typeParam JSDoc annotations --- packages/ui/src/internal/index.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/internal/index.ts b/packages/ui/src/internal/index.ts index a47d5eb331f..5c4673fdd4b 100644 --- a/packages/ui/src/internal/index.ts +++ b/packages/ui/src/internal/index.ts @@ -25,8 +25,10 @@ export const UI_BRAND_SYMBOL_KEY = 'clerk:ui'; /** * UiVersion type that carries appearance type information via phantom property * Used for version pinning with hot loading + * + * @typeParam A - The appearance type for styling customization */ -export type UiVersion = { +export type UiVersion = { /** * Brand symbol to identify legitimate @clerk/ui exports at runtime */ @@ -43,8 +45,11 @@ export type UiVersion = { /** * UiModule type represents the ClerkUi class constructor * Used when bundling @clerk/ui directly instead of hot loading + * + * @typeParam A - The appearance type for styling customization + * @typeParam I - The instance type returned by the constructor (defaults to unknown for external consumers) */ -export type UiModule = { +export type UiModule = { /** * Brand symbol to identify legitimate @clerk/ui exports at runtime */ @@ -56,7 +61,7 @@ export type UiModule = { /** * Constructor signature - must be callable with new */ - new (...args: any[]): any; + new (...args: unknown[]): I; /** * Phantom property for type-level appearance inference * This property never exists at runtime @@ -68,8 +73,10 @@ export type UiModule = { * Ui type that accepts either: * - UiVersion: version pinning object for hot loading * - UiModule: ClerkUi class constructor for direct module usage + * + * @typeParam A - The appearance type for styling customization */ -export type Ui = UiVersion | UiModule; +export type Ui = UiVersion | UiModule; export type { AlphaColorScale, From 4cccd8d6c81e2dc3d469a2cb73cf3d22905620ee Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 9 Jan 2026 21:59:35 -0600 Subject: [PATCH 7/8] fix(ui): Handle unknown in ExtractAppearanceType helper Update the type check to use 'unknown extends A' which correctly handles both 'any' and 'unknown' type parameters, falling back to the Default type in both cases. --- packages/ui/src/internal/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/internal/index.ts b/packages/ui/src/internal/index.ts index 5c4673fdd4b..da39c2679f5 100644 --- a/packages/ui/src/internal/index.ts +++ b/packages/ui/src/internal/index.ts @@ -6,11 +6,11 @@ export type { WithInternalRouting } from './routing'; /** * Extracts the appearance type from a Ui object. We got 3 cases: * - If the Ui type has __appearanceType with a specific type, extract it - * - If __appearanceType is 'any', fallback to base Appearance type + * - If __appearanceType is 'any' or 'unknown', fallback to base Appearance type * - Otherwise, fallback to the base Appearance type */ export type ExtractAppearanceType = T extends { __appearanceType?: infer A } - ? 0 extends 1 & A // Check if A is 'any' (this trick works because 1 & any = any, and 0 extends any) + ? unknown extends A // If A is 'any' or 'unknown', fallback to Default ? Default : A : Default; From 7c549ba5ef783689524d051858c4a525ea64c6e5 Mon Sep 17 00:00:00 2001 From: Jacek Date: Sat, 10 Jan 2026 07:53:19 -0600 Subject: [PATCH 8/8] fix(astro,vue): Preserve clerkUiUrl when ui prop not provided When the ui prop is not provided or doesn't include a url, preserve the existing clerkUiUrl option instead of overwriting it with undefined. This fixes integration tests that rely on the default UI loading behavior. --- packages/astro/src/internal/create-clerk-instance.ts | 3 ++- packages/vue/src/plugin.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/astro/src/internal/create-clerk-instance.ts b/packages/astro/src/internal/create-clerk-instance.ts index 7c17ef9bac4..f07c2f498bf 100644 --- a/packages/astro/src/internal/create-clerk-instance.ts +++ b/packages/astro/src/internal/create-clerk-instance.ts @@ -159,7 +159,8 @@ async function getClerkUiEntryChunk( ? { ...options, clerkUiVersion: uiVersion?.version, - clerkUiUrl: uiVersion?.url, + // Only override clerkUiUrl if uiVersion provides a url, otherwise keep existing + clerkUiUrl: uiVersion?.url || options.clerkUiUrl, } : undefined, ); diff --git a/packages/vue/src/plugin.ts b/packages/vue/src/plugin.ts index e4c6ad34ea6..2e7e8f71300 100644 --- a/packages/vue/src/plugin.ts +++ b/packages/vue/src/plugin.ts @@ -118,7 +118,8 @@ export const clerkPlugin: Plugin<[PluginOptions]> = { await loadClerkUiScript({ ...options, clerkUiVersion: uiVersion?.version, - clerkUiUrl: uiVersion?.url, + // Only override clerkUiUrl if uiVersion provides a url, otherwise keep existing + clerkUiUrl: uiVersion?.url || pluginOptions.clerkUiUrl, }); if (!window.__internal_ClerkUiCtor) { throw new Error('Failed to download latest Clerk UI. Contact support@clerk.com.');