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. 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..f07c2f498bf 100644 --- a/packages/astro/src/internal/create-clerk-instance.ts +++ b/packages/astro/src/internal/create-clerk-instance.ts @@ -108,6 +108,37 @@ 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' && 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. @@ -115,11 +146,24 @@ 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); + // 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 support@clerk.com.'); diff --git a/packages/astro/src/types.ts b/packages/astro/src/types.ts index 7f0613e5968..d4012666add 100644 --- a/packages/astro/src/types.ts +++ b/packages/astro/src/types.ts @@ -36,6 +36,14 @@ type AstroClerkIntegrationParams = 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 = AstroClerkIntegrationParams & { diff --git a/packages/chrome-extension/src/react/ClerkProvider.tsx b/packages/chrome-extension/src/react/ClerkProvider.tsx index 45237484496..8c676092448 100644 --- a/packages/chrome-extension/src/react/ClerkProvider.tsx +++ b/packages/chrome-extension/src/react/ClerkProvider.tsx @@ -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 = 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 +19,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 +37,7 @@ export function ClerkProvider(props: ChromeExtensionClerkPr {children} 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/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index b39bf352b8c..2acc37a7d24 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -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 { - 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, diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index c769d58afac..0612a16ec44 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -53,9 +53,25 @@ 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: + * - 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'; + * + * + * @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..1444928bfc3 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,24 @@ 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: + * - 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'; + * + * + * @example + * // Direct module usage (bundled with your app) + * import { ClerkUI } from '@clerk/ui'; + * */ - ui?: { version: string; url?: string }; + 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 cc3aa52b41b..b63b17e384a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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'; + * + */ +export const version = { + __brand: UI_BRAND_SYMBOL, 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. + * + * @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 2a9e39b207e..da39c2679f5 100644 --- a/packages/ui/src/internal/index.ts +++ b/packages/ui/src/internal/index.ts @@ -6,34 +6,77 @@ 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; -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 + * Used for version pinning with hot loading + * + * @typeParam A - The appearance type for styling customization + */ +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 + * + * @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 = { + /** + * 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: unknown[]): I; + /** + * Phantom property for type-level appearance inference + * This property never exists at runtime + */ + __appearanceType?: A; +}; /** - * Ui type that carries appearance type information via phantom property - * Tagged to ensure only official ui objects from @clerk/ui can be used + * 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 = Tagged< - { - version: string; - url?: string; - /** - * Phantom property for type-level appearance inference - * This property never exists at runtime - */ - __appearanceType?: A; - }, - 'ClerkUi' ->; +export type Ui = UiVersion | UiModule; export type { AlphaColorScale, @@ -92,6 +135,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 Ui; +} as UiVersion; diff --git a/packages/vue/src/plugin.ts b/packages/vue/src/plugin.ts index 91a89ffe91d..2e7e8f71300 100644 --- a/packages/vue/src/plugin.ts +++ b/packages/vue/src/plugin.ts @@ -72,16 +72,55 @@ 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' && 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 // 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 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, + // 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.'); }