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/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/vue/src/__tests__/plugin.test.ts b/packages/vue/src/__tests__/plugin.test.ts
new file mode 100644
index 00000000000..25f697fda06
--- /dev/null
+++ b/packages/vue/src/__tests__/plugin.test.ts
@@ -0,0 +1,182 @@
+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 CDN 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('passes clerkUIVersion from pluginOptions.ui.version to loadClerkUiScript', async () => {
+ mockLoadClerkUiScript.mockImplementation(async () => {
+ window.__internal_ClerkUICtor = mockClerkUICtor as any;
+ return null;
+ });
+
+ render(TestComponent, {
+ global: {
+ plugins: [
+ [
+ clerkPlugin,
+ {
+ publishableKey: 'pk_test_xxx',
+ ui: {
+ version: '1.2.3',
+ },
+ },
+ ],
+ ],
+ },
+ });
+
+ await vi.waitFor(() => {
+ expect(mockLoadClerkUiScript).toHaveBeenCalled();
+ });
+
+ const loadClerkUiScriptCall = mockLoadClerkUiScript.mock.calls[0][0];
+ expect(loadClerkUiScriptCall.clerkUIVersion).toBe('1.2.3');
+ expect(loadClerkUiScriptCall.clerkUIUrl).toBeUndefined();
+ });
+
+ it('passes clerkUIUrl from pluginOptions.ui.url to loadClerkUiScript', async () => {
+ mockLoadClerkUiScript.mockImplementation(async () => {
+ window.__internal_ClerkUICtor = mockClerkUICtor as any;
+ return null;
+ });
+
+ render(TestComponent, {
+ global: {
+ plugins: [
+ [
+ clerkPlugin,
+ {
+ publishableKey: 'pk_test_xxx',
+ ui: {
+ url: 'https://custom.cdn.example.com/ui.js',
+ },
+ },
+ ],
+ ],
+ },
+ });
+
+ await vi.waitFor(() => {
+ expect(mockLoadClerkUiScript).toHaveBeenCalled();
+ });
+
+ const loadClerkUiScriptCall = mockLoadClerkUiScript.mock.calls[0][0];
+ expect(loadClerkUiScriptCall.clerkUIUrl).toBe('https://custom.cdn.example.com/ui.js');
+ expect(loadClerkUiScriptCall.clerkUIVersion).toBeUndefined();
+ });
+
+ it('passes both clerkUIVersion and clerkUIUrl when both are provided', async () => {
+ mockLoadClerkUiScript.mockImplementation(async () => {
+ window.__internal_ClerkUICtor = mockClerkUICtor as any;
+ return null;
+ });
+
+ render(TestComponent, {
+ global: {
+ plugins: [
+ [
+ clerkPlugin,
+ {
+ publishableKey: 'pk_test_xxx',
+ ui: {
+ version: '2.0.0',
+ url: 'https://custom.cdn.example.com/ui-v2.js',
+ },
+ },
+ ],
+ ],
+ },
+ });
+
+ await vi.waitFor(() => {
+ expect(mockLoadClerkUiScript).toHaveBeenCalled();
+ });
+
+ const loadClerkUiScriptCall = mockLoadClerkUiScript.mock.calls[0][0];
+ expect(loadClerkUiScriptCall.clerkUIVersion).toBe('2.0.0');
+ expect(loadClerkUiScriptCall.clerkUIUrl).toBe('https://custom.cdn.example.com/ui-v2.js');
+ });
+
+ it('ClerkUIPromise resolves to window.__internal_ClerkUICtor after loadClerkUiScript completes', async () => {
+ let capturedLoadOptions: any;
+
+ mockLoadClerkUiScript.mockImplementation(async () => {
+ window.__internal_ClerkUICtor = mockClerkUICtor as any;
+ return null;
+ });
+
+ 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: {
+ version: '1.0.0',
+ },
+ },
+ ],
+ ],
+ },
+ });
+
+ await vi.waitFor(() => {
+ expect(capturedLoadOptions).toBeDefined();
+ });
+
+ const resolvedClerkUI = await capturedLoadOptions.ui.ClerkUI;
+ expect(resolvedClerkUI).toBe(mockClerkUICtor);
+ });
+});
diff --git a/packages/vue/src/plugin.ts b/packages/vue/src/plugin.ts
index c6ff6e9d3a6..a397932589f 100644
--- a/packages/vue/src/plugin.ts
+++ b/packages/vue/src/plugin.ts
@@ -27,6 +27,13 @@ export type PluginOptions = Without;
+ /**
+ * UI object for Clerk UI components.
+ * Can include version/url for CDN loading, or ClerkUI constructor for bundled usage.
+ */
+ ui?: TUi & {
+ ClerkUI?: ClerkUiConstructor;
+ };
};
const SDK_METADATA = {
@@ -79,17 +86,26 @@ export const clerkPlugin: Plugin<[PluginOptions]> = {
try {
const clerkPromise = loadClerkJSScript(options);
// Honor explicit clerkUICtor even when prefetchUI={false}
+ // Also support the new ui prop with version/url/ClerkUI
+ 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 () => {
+ const uiScriptOptions = {
+ ...options,
+ clerkUIVersion: uiProp?.version,
+ clerkUIUrl: uiProp?.url,
+ };
+ await loadClerkUIScript(uiScriptOptions);
+ 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 +114,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;