diff --git a/.changeset/shiny-owls-dance.md b/.changeset/shiny-owls-dance.md
new file mode 100644
index 00000000000..62aaa93a156
--- /dev/null
+++ b/.changeset/shiny-owls-dance.md
@@ -0,0 +1,19 @@
+---
+'@clerk/ui': minor
+'@clerk/react': minor
+'@clerk/vue': minor
+'@clerk/astro': minor
+'@clerk/chrome-extension': minor
+'@clerk/shared': minor
+---
+
+Add `ui` prop to ClerkProvider for passing `@clerk/ui`
+
+Usage:
+```tsx
+import { ui } from '@clerk/ui';
+
+
+ ...
+
+```
diff --git a/packages/astro/package.json b/packages/astro/package.json
index 59f07aacf78..a2b8c28f586 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -86,7 +86,8 @@
"lint": "eslint src env.d.ts",
"lint:attw": "attw --pack . --profile esm-only --ignore-rules internal-resolution-error",
"lint:publint": "pnpm copy:components && publint",
- "publish:local": "pnpm yalc push --replace --sig"
+ "publish:local": "pnpm yalc push --replace --sig",
+ "test": "vitest run"
},
"dependencies": {
"@clerk/backend": "workspace:^",
diff --git a/packages/astro/src/internal/__tests__/create-clerk-instance.test.ts b/packages/astro/src/internal/__tests__/create-clerk-instance.test.ts
new file mode 100644
index 00000000000..e9f62f67a99
--- /dev/null
+++ b/packages/astro/src/internal/__tests__/create-clerk-instance.test.ts
@@ -0,0 +1,97 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mockLoadClerkUIScript = vi.fn();
+const mockLoadClerkJSScript = vi.fn();
+
+vi.mock('@clerk/shared/loadClerkJsScript', () => ({
+ loadClerkJSScript: (...args: unknown[]) => mockLoadClerkJSScript(...args),
+ loadClerkUIScript: (...args: unknown[]) => mockLoadClerkUIScript(...args),
+ setClerkJSLoadingErrorPackageName: vi.fn(),
+}));
+
+// Mock nanostores
+vi.mock('../../stores/external', () => ({
+ $clerkStore: { notify: vi.fn() },
+}));
+
+vi.mock('../../stores/internal', () => ({
+ $clerk: { get: vi.fn(), set: vi.fn() },
+ $csrState: { setKey: vi.fn() },
+}));
+
+vi.mock('../invoke-clerk-astro-js-functions', () => ({
+ invokeClerkAstroJSFunctions: vi.fn(),
+}));
+
+vi.mock('../mount-clerk-astro-js-components', () => ({
+ mountAllClerkAstroJSComponents: vi.fn(),
+}));
+
+const mockClerkUICtor = vi.fn();
+
+describe('getClerkUIEntryChunk', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.resetModules();
+ (window as any).__internal_ClerkUICtor = undefined;
+ (window as any).Clerk = undefined;
+ });
+
+ afterEach(() => {
+ (window as any).__internal_ClerkUICtor = undefined;
+ (window as any).Clerk = undefined;
+ });
+
+ it('preserves clerkUIUrl from options', async () => {
+ mockLoadClerkUIScript.mockImplementation(async () => {
+ (window as any).__internal_ClerkUICtor = mockClerkUICtor;
+ return null;
+ });
+
+ mockLoadClerkJSScript.mockImplementation(async () => {
+ (window as any).Clerk = {
+ load: vi.fn().mockResolvedValue(undefined),
+ addListener: vi.fn(),
+ };
+ return null;
+ });
+
+ // Dynamically import to get fresh module with mocks
+ const { createClerkInstance } = await import('../create-clerk-instance');
+
+ // Call createClerkInstance with clerkUIUrl
+ await createClerkInstance({
+ publishableKey: 'pk_test_xxx',
+ clerkUIUrl: 'https://custom.selfhosted.example.com/ui.js',
+ });
+
+ expect(mockLoadClerkUIScript).toHaveBeenCalled();
+ const loadClerkUIScriptCall = mockLoadClerkUIScript.mock.calls[0]?.[0] as Record;
+ expect(loadClerkUIScriptCall?.clerkUIUrl).toBe('https://custom.selfhosted.example.com/ui.js');
+ });
+
+ it('does not set clerkUIUrl when not provided', async () => {
+ mockLoadClerkUIScript.mockImplementation(async () => {
+ (window as any).__internal_ClerkUICtor = mockClerkUICtor;
+ return null;
+ });
+
+ mockLoadClerkJSScript.mockImplementation(async () => {
+ (window as any).Clerk = {
+ load: vi.fn().mockResolvedValue(undefined),
+ addListener: vi.fn(),
+ };
+ return null;
+ });
+
+ const { createClerkInstance } = await import('../create-clerk-instance');
+
+ await createClerkInstance({
+ publishableKey: 'pk_test_xxx',
+ });
+
+ expect(mockLoadClerkUIScript).toHaveBeenCalled();
+ const loadClerkUIScriptCall = mockLoadClerkUIScript.mock.calls[0]?.[0] as Record;
+ expect(loadClerkUIScriptCall?.clerkUIUrl).toBeUndefined();
+ });
+});
diff --git a/packages/astro/src/internal/create-injection-script-runner.ts b/packages/astro/src/internal/create-injection-script-runner.ts
index e07b298edc0..422fdca3c98 100644
--- a/packages/astro/src/internal/create-injection-script-runner.ts
+++ b/packages/astro/src/internal/create-injection-script-runner.ts
@@ -22,7 +22,9 @@ function createInjectionScriptRunner(creator: CreateClerkInstanceInternalFn) {
clientSafeVars = JSON.parse(clientSafeVarsContainer.textContent || '{}');
}
- await creator(mergeEnvVarsWithParams({ ...astroClerkOptions, ...clientSafeVars }));
+ await creator({
+ ...mergeEnvVarsWithParams({ ...astroClerkOptions, ...clientSafeVars }),
+ });
}
return runner;
diff --git a/packages/astro/vitest.config.ts b/packages/astro/vitest.config.ts
new file mode 100644
index 00000000000..9dbc1341d39
--- /dev/null
+++ b/packages/astro/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: ['./vitest.setup.ts'],
+ },
+});
diff --git a/packages/astro/vitest.setup.ts b/packages/astro/vitest.setup.ts
new file mode 100644
index 00000000000..f1792b77288
--- /dev/null
+++ b/packages/astro/vitest.setup.ts
@@ -0,0 +1,6 @@
+import { vi } from 'vitest';
+
+import packageJson from './package.json';
+
+vi.stubGlobal('PACKAGE_NAME', packageJson.name);
+vi.stubGlobal('PACKAGE_VERSION', packageJson.version);
diff --git a/packages/react-router/src/client/__tests__/ClerkProvider.test.tsx b/packages/react-router/src/client/__tests__/ClerkProvider.test.tsx
new file mode 100644
index 00000000000..6164770edb3
--- /dev/null
+++ b/packages/react-router/src/client/__tests__/ClerkProvider.test.tsx
@@ -0,0 +1,92 @@
+import { render } from '@testing-library/react';
+import React from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mockClerkProvider = vi.fn(({ children }: { children: React.ReactNode }) => {children}
);
+
+vi.mock('@clerk/react', () => ({
+ ClerkProvider: (props: any) => mockClerkProvider(props),
+}));
+
+vi.mock('react-router', () => ({
+ useNavigate: () => vi.fn(),
+ useLocation: () => ({ pathname: '/' }),
+ UNSAFE_DataRouterContext: React.createContext(null),
+}));
+
+vi.mock('../../utils/assert', () => ({
+ assertPublishableKeyInSpaMode: vi.fn(),
+ assertValidClerkState: vi.fn(),
+ isSpaMode: () => true,
+ warnForSsr: vi.fn(),
+}));
+
+describe('ClerkProvider clerkUIUrl prop', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('passes clerkUIUrl prop to the underlying ClerkProvider', async () => {
+ const { ClerkProvider } = await import('../ReactRouterClerkProvider');
+
+ render(
+
+ Test
+ ,
+ );
+
+ expect(mockClerkProvider).toHaveBeenCalledWith(
+ expect.objectContaining({
+ clerkUIUrl: 'https://custom.clerk.ui/ui.js',
+ }),
+ );
+ });
+
+ it('passes clerkUIUrl as undefined when not provided', async () => {
+ const { ClerkProvider } = await import('../ReactRouterClerkProvider');
+
+ render(
+
+ Test
+ ,
+ );
+
+ expect(mockClerkProvider).toHaveBeenCalledWith(
+ expect.objectContaining({
+ clerkUIUrl: undefined,
+ }),
+ );
+ });
+
+ it('passes clerkUIUrl alongside other props', async () => {
+ const { ClerkProvider } = await import('../ReactRouterClerkProvider');
+
+ render(
+
+ Test
+ ,
+ );
+
+ expect(mockClerkProvider).toHaveBeenCalledWith(
+ expect.objectContaining({
+ clerkUIUrl: 'https://custom.clerk.ui/ui.js',
+ clerkJSUrl: 'https://custom.clerk.js/clerk.js',
+ signInUrl: '/sign-in',
+ signUpUrl: '/sign-up',
+ }),
+ );
+ });
+});
diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts
index b125ff4e096..398eca801c7 100644
--- a/packages/react/src/isomorphicClerk.ts
+++ b/packages/react/src/isomorphicClerk.ts
@@ -519,6 +519,12 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
return this.options.clerkUICtor;
}
+ // Support bundled UI via ui.ClerkUI prop
+ const uiProp = (this.options as { ui?: { ClerkUI?: ClerkUiConstructor } }).ui;
+ if (uiProp?.ClerkUI) {
+ return uiProp.ClerkUI;
+ }
+
if (this.options.prefetchUI === false) {
return undefined;
}
diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts
index ed48b1d3a56..4cef2ea0566 100644
--- a/packages/react/src/types.ts
+++ b/packages/react/src/types.ts
@@ -53,8 +53,9 @@ 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.
+ * Optional object to use the bundled Clerk UI instead of loading from CDN.
+ * Import `ui` from `@clerk/ui` and pass it here to bundle the UI with your application.
+ * When omitted, UI is loaded from Clerk's CDN.
* Note: When `ui` is used, appearance is automatically typed based on the specific UI version.
*/
ui?: TUi;
diff --git a/packages/shared/src/ui/types.ts b/packages/shared/src/ui/types.ts
index 8b7e5ec4d73..761717b0fd8 100644
--- a/packages/shared/src/ui/types.ts
+++ b/packages/shared/src/ui/types.ts
@@ -30,7 +30,7 @@ export interface ClerkUiInstance {
}
// Constructor type
-export interface ClerkUiConstructor {
+export interface ClerkUIConstructor {
new (
getClerk: () => Clerk,
getEnvironment: () => EnvironmentResource | null | undefined,
@@ -41,3 +41,6 @@ export interface ClerkUiConstructor {
}
export type ClerkUi = ClerkUiInstance;
+
+// Alias for compatibility with main branch naming convention
+export type ClerkUiConstructor = ClerkUIConstructor;
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index cc3aa52b41b..7d63ecca42e 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -1,12 +1,24 @@
import type { Ui } from './internal';
import type { Appearance } from './internal/appearance';
+import { ClerkUi } 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
+ * UI object for Clerk UI components.
+ * Pass this to ClerkProvider to use the bundled UI.
+ *
+ * @example
+ * ```tsx
+ * import { ui } from '@clerk/ui';
+ *
+ *
+ * ...
+ *
+ * ```
*/
export const ui = {
version: PACKAGE_VERSION,
+ ClerkUI: ClerkUi,
} as Ui;
diff --git a/packages/ui/src/internal/index.ts b/packages/ui/src/internal/index.ts
index 2a9e39b207e..d15ef2ed927 100644
--- a/packages/ui/src/internal/index.ts
+++ b/packages/ui/src/internal/index.ts
@@ -1,3 +1,5 @@
+import type { ClerkUiConstructor } from '@clerk/shared/ui';
+
import type { Appearance } from './appearance';
export type { ComponentControls, MountComponentRenderer } from '../Components';
@@ -24,8 +26,11 @@ type Tagged = BaseType & { [Tags]: { [K in Ta
*/
export type Ui = Tagged<
{
- version: string;
- url?: string;
+ ClerkUI: ClerkUiConstructor;
+ /**
+ * Version of the UI package (for potential future use)
+ */
+ version?: string;
/**
* Phantom property for type-level appearance inference
* This property never exists at runtime
@@ -86,12 +91,3 @@ export type {
// UserPreviewId,
// } from './internal/elementIds';
-/**
- * @internal
- * Local ui object for testing purposes
- * Do not use
- */
-export const localUiForTesting = {
- version: PACKAGE_VERSION,
- url: 'http://localhost:4011/npm/ui.browser.js',
-} as Ui;
diff --git a/packages/vue/src/__tests__/plugin.test.ts b/packages/vue/src/__tests__/plugin.test.ts
new file mode 100644
index 00000000000..f01baca6aec
--- /dev/null
+++ b/packages/vue/src/__tests__/plugin.test.ts
@@ -0,0 +1,150 @@
+import { render } from '@testing-library/vue';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { defineComponent } from 'vue';
+
+import { clerkPlugin } from '../plugin';
+
+const mockLoadClerkUiScript = vi.fn();
+const mockLoadClerkJsScript = vi.fn();
+
+vi.mock('@clerk/shared/loadClerkJsScript', () => ({
+ loadClerkJSScript: (...args: unknown[]) => mockLoadClerkJsScript(...args),
+ loadClerkUIScript: (...args: unknown[]) => mockLoadClerkUiScript(...args),
+}));
+
+vi.mock('@clerk/shared/browser', () => ({
+ inBrowser: () => true,
+}));
+
+const mockClerkUICtor = vi.fn();
+
+describe('clerkPlugin UI loading', () => {
+ const originalWindowClerk = window.Clerk;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ window.__internal_ClerkUICtor = undefined;
+ (window as any).Clerk = undefined;
+
+ mockLoadClerkJsScript.mockImplementation(async () => {
+ (window as any).Clerk = {
+ load: vi.fn().mockResolvedValue(undefined),
+ addListener: vi.fn(),
+ };
+ return null;
+ });
+ });
+
+ afterEach(() => {
+ (window as any).Clerk = originalWindowClerk;
+ window.__internal_ClerkUICtor = undefined;
+ });
+
+ const TestComponent = defineComponent({
+ template: 'Test
',
+ });
+
+ it('loads UI from CDN when no ui prop is provided', async () => {
+ mockLoadClerkUiScript.mockImplementation(async () => {
+ window.__internal_ClerkUICtor = mockClerkUICtor as any;
+ return null;
+ });
+
+ render(TestComponent, {
+ global: {
+ plugins: [
+ [
+ clerkPlugin,
+ {
+ publishableKey: 'pk_test_xxx',
+ },
+ ],
+ ],
+ },
+ });
+
+ await vi.waitFor(() => {
+ expect(mockLoadClerkUiScript).toHaveBeenCalled();
+ });
+ });
+
+ it('uses bundled UI when ui.ClerkUI is provided', async () => {
+ let capturedLoadOptions: any;
+
+ mockLoadClerkJsScript.mockImplementation(async () => {
+ (window as any).Clerk = {
+ load: vi.fn().mockImplementation(async (opts: any) => {
+ capturedLoadOptions = opts;
+ }),
+ addListener: vi.fn(),
+ };
+ return null;
+ });
+
+ render(TestComponent, {
+ global: {
+ plugins: [
+ [
+ clerkPlugin,
+ {
+ publishableKey: 'pk_test_xxx',
+ ui: {
+ ClerkUI: mockClerkUICtor,
+ },
+ },
+ ],
+ ],
+ },
+ });
+
+ await vi.waitFor(() => {
+ expect(capturedLoadOptions).toBeDefined();
+ });
+
+ // Should not load from CDN
+ expect(mockLoadClerkUiScript).not.toHaveBeenCalled();
+
+ // Should pass the bundled ClerkUI constructor
+ const resolvedClerkUI = await capturedLoadOptions.ui.ClerkUI;
+ expect(resolvedClerkUI).toBe(mockClerkUICtor);
+ });
+
+ it('does not load UI when prefetchUI is false', async () => {
+ let capturedLoadOptions: any;
+
+ mockLoadClerkJsScript.mockImplementation(async () => {
+ (window as any).Clerk = {
+ load: vi.fn().mockImplementation(async (opts: any) => {
+ capturedLoadOptions = opts;
+ }),
+ addListener: vi.fn(),
+ };
+ return null;
+ });
+
+ render(TestComponent, {
+ global: {
+ plugins: [
+ [
+ clerkPlugin,
+ {
+ publishableKey: 'pk_test_xxx',
+ prefetchUI: false,
+ },
+ ],
+ ],
+ },
+ });
+
+ await vi.waitFor(() => {
+ expect(capturedLoadOptions).toBeDefined();
+ });
+
+ // Should not load from CDN
+ expect(mockLoadClerkUiScript).not.toHaveBeenCalled();
+
+ // ClerkUI should be undefined (headless mode)
+ const resolvedClerkUI = await capturedLoadOptions.ui.ClerkUI;
+ expect(resolvedClerkUI).toBeUndefined();
+ });
+});
diff --git a/packages/vue/src/plugin.ts b/packages/vue/src/plugin.ts
index c6ff6e9d3a6..11ae46387b2 100644
--- a/packages/vue/src/plugin.ts
+++ b/packages/vue/src/plugin.ts
@@ -27,6 +27,12 @@ export type PluginOptions = Without;
+ /**
+ * Optional object to use the bundled Clerk UI instead of loading from CDN.
+ * Import `ui` from `@clerk/ui` and pass it here to bundle the UI with your application.
+ * When omitted, UI is loaded from Clerk's CDN.
+ */
+ ui?: TUi;
};
const SDK_METADATA = {
@@ -79,17 +85,21 @@ export const clerkPlugin: Plugin<[PluginOptions]> = {
try {
const clerkPromise = loadClerkJSScript(options);
// Honor explicit clerkUICtor even when prefetchUI={false}
+ // Also support bundled UI via ui.ClerkUI prop
+ const uiProp = pluginOptions.ui;
const clerkUICtorPromise = pluginOptions.clerkUICtor
? Promise.resolve(pluginOptions.clerkUICtor)
- : pluginOptions.prefetchUI === false
- ? Promise.resolve(undefined)
- : (async () => {
- await loadClerkUIScript(options);
- if (!window.__internal_ClerkUICtor) {
- throw new Error('Failed to download latest Clerk UI. Contact support@clerk.com.');
- }
- return window.__internal_ClerkUICtor;
- })();
+ : uiProp?.ClerkUI
+ ? Promise.resolve(uiProp.ClerkUI)
+ : pluginOptions.prefetchUI === false
+ ? Promise.resolve(undefined)
+ : (async () => {
+ await loadClerkUIScript(options);
+ if (!window.__internal_ClerkUICtor) {
+ throw new Error('Failed to download latest Clerk UI. Contact support@clerk.com.');
+ }
+ return window.__internal_ClerkUICtor;
+ })();
await clerkPromise;
@@ -98,7 +108,7 @@ export const clerkPlugin: Plugin<[PluginOptions]> = {
}
clerk.value = window.Clerk;
- const loadOptions = { ...options, clerkUICtor: clerkUICtorPromise } as unknown as ClerkOptions;
+ const loadOptions = { ...options, ui: { ClerkUI: clerkUICtorPromise } } as unknown as ClerkOptions;
await window.Clerk.load(loadOptions);
loaded.value = true;