Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c5ff0d1
feat(react,vue,astro): Add ui prop for version metadata
jacekradko Jan 23, 2026
d8370e0
chore: add changeset for ui prop
jacekradko Jan 23, 2026
67fdc89
refactor: use ui prop instead of clerkUiCtor in chrome-extension
jacekradko Jan 23, 2026
2399eff
refactor: remove clerkUiCtor from public API, add __internal_forceBun…
jacekradko Jan 23, 2026
89bb1ff
chore: update changeset description
jacekradko Jan 23, 2026
ccac639
refactor: rename clerkUiCtor to ClerkUI and ClerkUiConstructor to Cle…
jacekradko Jan 23, 2026
cf2a354
refactor: rename __internal_ClerkUiCtor to __internal_ClerkUICtor
jacekradko Jan 23, 2026
1d2c8cf
chore: simplify changeset
jacekradko Jan 24, 2026
8f705a8
refactor: rename ui.ctor to ui.ClerkUI
jacekradko Jan 24, 2026
478a0b3
Merge branch 'main' into jrad/ui-prop-cleanup
jacekradko Jan 24, 2026
9517d40
refactor: move ClerkUI inside ui object in ClerkOptions
jacekradko Jan 24, 2026
890cd43
fix: rename ctor to ClerkUI in ui export
jacekradko Jan 24, 2026
7f7665f
chore: fix formatting
jacekradko Jan 24, 2026
a3014de
chore: fix es-ES.ts formatting
jacekradko Jan 24, 2026
9774008
fix(astro): Remove unnecessary eslint-disable directive
jacekradko Jan 24, 2026
e81f20a
test(vue): add unit tests for CDN UI loading with version pinning
jacekradko Jan 24, 2026
d3c96f3
fix(astro): honor bundled UI constructor in getClerkUiEntryChunk
jacekradko Jan 24, 2026
aa0c84b
fix(astro): preserve clerkUiUrl fallback in loadClerkUiScript call
jacekradko Jan 24, 2026
64ff3dc
fix: standardize casing for clerkUIUrl and clerkUIVersion properties
jacekradko Jan 24, 2026
76d7c96
fix(vue): preserve clerkUIUrl fallback when ui.url is not set
jacekradko Jan 24, 2026
c0d3b6f
fix(vue): use type cast for clerkUIUrl/clerkUIVersion fallback
jacekradko Jan 24, 2026
61655b1
fix: update integration templates to use clerkUIUrl casing
jacekradko Jan 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/shiny-owls-dance.md
Original file line number Diff line number Diff line change
@@ -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';

<ClerkProvider ui={ui}>
...
</ClerkProvider>
```
2 changes: 1 addition & 1 deletion integration/templates/custom-flows-react-vite/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ createRoot(document.getElementById('root')!).render(
<div className='flex w-full max-w-sm flex-col gap-6'>
<ClerkProvider
clerkJSUrl={import.meta.env.VITE_CLERK_JS_URL as string}
clerkUiUrl={import.meta.env.VITE_CLERK_UI_URL as string}
clerkUIUrl={import.meta.env.VITE_CLERK_UI_URL as string}
appearance={{
options: {
showOptionalFields: true,
Expand Down
2 changes: 1 addition & 1 deletion integration/templates/expo-web/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default function RootLayout() {
routerPush={(to: string) => router.push(to)}
routerReplace={to => router.replace(to)}
clerkJSUrl={process.env.EXPO_PUBLIC_CLERK_JS_URL}
clerkUiUrl={process.env.EXPO_PUBLIC_CLERK_UI_URL}
clerkUIUrl={process.env.EXPO_PUBLIC_CLERK_UI_URL}
appearance={{
options: {
showOptionalFields: true,
Expand Down
6 changes: 2 additions & 4 deletions integration/templates/express-vite/src/client/main.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { Clerk } from '@clerk/clerk-js';
import { ClerkUi } from '@clerk/ui/entry';
import { ui } from '@clerk/ui';

const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;

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

await clerk.load({
clerkUiCtor: ClerkUi,
});
await clerk.load({ ui });

if (clerk.isSignedIn) {
document.getElementById('app')!.innerHTML = `
Expand Down
2 changes: 1 addition & 1 deletion integration/templates/react-cra/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ root.render(
<ClerkProvider
publishableKey={process.env.REACT_APP_CLERK_PUBLISHABLE_KEY as string}
clerkJSUrl={process.env.REACT_APP_CLERK_JS as string}
clerkUiUrl={process.env.REACT_APP_CLERK_UI as string}
clerkUIUrl={process.env.REACT_APP_CLERK_UI as string}
appearance={{
options: {
showOptionalFields: true,
Expand Down
2 changes: 1 addition & 1 deletion integration/templates/react-router-library/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ createRoot(document.getElementById('root')!).render(
<ClerkProvider
publishableKey={PUBLISHABLE_KEY}
clerkJSUrl={CLERK_JS_URL}
clerkUiUrl={CLERK_UI_URL}
clerkUIUrl={CLERK_UI_URL}
Comment on lines 15 to +18
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add coverage for the renamed clerkUIUrl prop.

This is a public API change in the template; please add or update tests (or an integration/template validation) to ensure the new prop name is exercised and prevents regressions.

🤖 Prompt for AI Agents
In `@integration/templates/react-router-library/src/main.tsx` around lines 15 -
18, The template now uses the renamed ClerkProvider prop clerkUIUrl; update or
add tests to exercise this public API change by asserting that ClerkProvider is
rendered with clerkUIUrl (not the old prop) in integration/template validation
or unit tests—locate usages of ClerkProvider in main.tsx and update any existing
tests referencing the old prop name to pass clerkUIUrl and verify the provider
receives and uses that value (e.g., via shallow render, snapshot, or template
validation).

appearance={{
options: {
showOptionalFields: true,
Expand Down
2 changes: 1 addition & 1 deletion integration/templates/react-router-node/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
<ClerkProvider
loaderData={loaderData}
clerkJSUrl={import.meta.env.VITE_CLERK_JS_URL}
clerkUiUrl={import.meta.env.VITE_CLERK_UI_URL}
clerkUIUrl={import.meta.env.VITE_CLERK_UI_URL}
appearance={{
options: {
showOptionalFields: true,
Expand Down
2 changes: 1 addition & 1 deletion integration/templates/react-vite/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const Root = () => {
return (
<ClerkProvider
clerkJSUrl={import.meta.env.VITE_CLERK_JS_URL as string}
clerkUiUrl={import.meta.env.VITE_CLERK_UI_URL as string}
clerkUIUrl={import.meta.env.VITE_CLERK_UI_URL as string}
routerPush={(to: string) => navigate(to)}
routerReplace={(to: string) => navigate(to, { replace: true })}
appearance={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<body>
<ClerkProvider
clerkJSUrl={import.meta.env.VITE_CLERK_JS_URL}
clerkUiUrl={import.meta.env.VITE_CLERK_UI_URL}
clerkUIUrl={import.meta.env.VITE_CLERK_UI_URL}
appearance={{
options: {
showOptionalFields: true,
Expand Down
2 changes: 1 addition & 1 deletion integration/templates/vue-vite/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const app = createApp(App);
app.use(clerkPlugin, {
publishableKey: import.meta.env.VITE_CLERK_PUBLISHABLE_KEY,
clerkJSUrl: import.meta.env.VITE_CLERK_JS_URL,
clerkUiUrl: import.meta.env.VITE_CLERK_UI_URL,
clerkUIUrl: import.meta.env.VITE_CLERK_UI_URL,
clerkJSVersion: import.meta.env.VITE_CLERK_JS_VERSION,
appearance: {
options: {
Expand Down
3 changes: 2 additions & 1 deletion packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,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:^",
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/integration/create-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function createIntegration<Params extends HotloadAstroClerkIntegrationParams>()

// These are not provided when the "bundled" integration is used
const clerkJSUrl = (params as any)?.clerkJSUrl as string | undefined;
const clerkUiUrl = (params as any)?.clerkUiUrl as string | undefined;
const clerkUIUrl = (params as any)?.clerkUIUrl as string | undefined;
const clerkJSVariant = (params as any)?.clerkJSVariant as string | undefined;
const clerkJSVersion = (params as any)?.clerkJSVersion as string | undefined;

Expand Down Expand Up @@ -61,7 +61,7 @@ function createIntegration<Params extends HotloadAstroClerkIntegrationParams>()
...buildEnvVarFromOption(proxyUrl, 'PUBLIC_CLERK_PROXY_URL'),
...buildEnvVarFromOption(domain, 'PUBLIC_CLERK_DOMAIN'),
...buildEnvVarFromOption(clerkJSUrl, 'PUBLIC_CLERK_JS_URL'),
...buildEnvVarFromOption(clerkUiUrl, 'PUBLIC_CLERK_UI_URL'),
...buildEnvVarFromOption(clerkUIUrl, 'PUBLIC_CLERK_UI_URL'),
...buildEnvVarFromOption(clerkJSVariant, 'PUBLIC_CLERK_JS_VARIANT'),
...buildEnvVarFromOption(clerkJSVersion, 'PUBLIC_CLERK_JS_VERSION'),
},
Expand Down
127 changes: 127 additions & 0 deletions packages/astro/src/internal/__tests__/create-clerk-instance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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 when options.ui.url is 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;
});

// Dynamically import to get fresh module with mocks
const { createClerkInstance } = await import('../create-clerk-instance');

// Call createClerkInstance with clerkUIUrl but without ui.url
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<string, unknown>;
expect(loadClerkUiScriptCall?.clerkUIUrl).toBe('https://custom.selfhosted.example.com/ui.js');
});

it('prefers options.ui.url over options.clerkUIUrl when both are 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',
clerkUIUrl: 'https://fallback.example.com/ui.js',
ui: {
version: '1.0.0',
url: 'https://preferred.example.com/ui.js',
} as any,
});

expect(mockLoadClerkUiScript).toHaveBeenCalled();
const loadClerkUiScriptCall = mockLoadClerkUiScript.mock.calls[0]?.[0] as Record<string, unknown>;
expect(loadClerkUiScriptCall?.clerkUIUrl).toBe('https://preferred.example.com/ui.js');
});

it('does not set clerkUIUrl when neither options.ui.url nor options.clerkUIUrl is 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<string, unknown>;
expect(loadClerkUiScriptCall?.clerkUIUrl).toBeUndefined();
});
});
40 changes: 27 additions & 13 deletions packages/astro/src/internal/create-clerk-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
setClerkJsLoadingErrorPackageName,
} from '@clerk/shared/loadClerkJsScript';
import type { ClerkOptions } from '@clerk/shared/types';
import type { ClerkUiConstructor } from '@clerk/shared/ui';
import type { ClerkUIConstructor } from '@clerk/shared/ui';
import type { Ui } from '@clerk/ui/internal';

import { $clerkStore } from '../stores/external';
Expand Down Expand Up @@ -40,7 +40,7 @@ async function createClerkInstanceInternal<TUi extends Ui = Ui>(options?: AstroC
// Both functions return early if the scripts are already loaded
// (e.g., via middleware-injected script tags in the HTML head).
const clerkJsChunk = getClerkJsEntryChunk(options);
const clerkUiCtor = getClerkUiEntryChunk(options);
const ClerkUI = getClerkUiEntryChunk(options);

await clerkJsChunk;

Expand All @@ -58,8 +58,12 @@ async function createClerkInstanceInternal<TUi extends Ui = Ui>(options?: AstroC
routerPush: createNavigationHandler(window.history.pushState.bind(window.history)),
routerReplace: createNavigationHandler(window.history.replaceState.bind(window.history)),
...options,
// Pass the clerk-ui constructor promise to clerk.load()
clerkUiCtor,
// Pass the clerk-ui constructor promise inside ui object
ui: {
version: options?.ui?.version,
url: options?.ui?.url,
ClerkUI,
},
} as unknown as ClerkOptions;

initOptions = clerkOptions;
Expand Down Expand Up @@ -109,23 +113,33 @@ async function getClerkJsEntryChunk<TUi extends Ui = Ui>(options?: AstroClerkCre
}

/**
* Gets the ClerkUI constructor, either from options or by loading the script.
* Returns early if window.__internal_ClerkUiCtor already exists.
* Gets the ClerkUI constructor, either from bundled UI or by loading from CDN.
*/
async function getClerkUiEntryChunk<TUi extends Ui = Ui>(
options?: AstroClerkCreateInstanceParams<TUi>,
): Promise<ClerkUiConstructor> {
if (options?.clerkUiCtor) {
return options.clerkUiCtor;
): Promise<ClerkUIConstructor> {
// Use bundled UI constructor if provided via options.ui.ClerkUI
const bundledClerkUI = (options?.ui as { ClerkUI?: ClerkUIConstructor })?.ClerkUI;
if (bundledClerkUI) {
return bundledClerkUI;
}

await loadClerkUiScript(options);

if (!window.__internal_ClerkUiCtor) {
// Fall back to loading UI from CDN with version pinning from ui.version
await loadClerkUiScript(
options
? {
...options,
clerkUIVersion: options.ui?.version,
clerkUIUrl: options.ui?.url ?? options.clerkUIUrl,
}
: undefined,
);

if (!window.__internal_ClerkUICtor) {
throw new Error('Failed to download latest Clerk UI. Contact support@clerk.com.');
}

return window.__internal_ClerkUiCtor;
return window.__internal_ClerkUICtor;
}

export { createClerkInstance, updateClerkOptions };
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ function createInjectionScriptRunner(creator: CreateClerkInstanceInternalFn) {
clientSafeVars = JSON.parse(clientSafeVarsContainer.textContent || '{}');
}

await creator(mergeEnvVarsWithParams({ ...astroClerkOptions, ...clientSafeVars }));
// Pass `ui` separately to avoid TypeScript declaration file issues with the
// branded Ui type that contains an unexported Tags symbol
await creator({
...mergeEnvVarsWithParams({ ...astroClerkOptions, ...clientSafeVars }),
ui: astroClerkOptions?.ui,
});
}

return runner;
Expand Down
7 changes: 5 additions & 2 deletions packages/astro/src/internal/merge-env-vars-with-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & { publish
publishableKey: paramPublishableKey,
telemetry: paramTelemetry,
clerkJSUrl: paramClerkJSUrl,
clerkUiUrl: paramClerkUiUrl,
clerkUIUrl: paramClerkUIUrl,
clerkJSVariant: paramClerkJSVariant,
clerkJSVersion: paramClerkJSVersion,
// Extract `ui` separately to avoid spreading the branded Ui type which contains
// an unexported Tags symbol that breaks TypeScript declaration file generation.
ui: _paramUi,
...rest
} = params || {};

Expand All @@ -28,7 +31,7 @@ const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & { publish
proxyUrl: paramProxy || import.meta.env.PUBLIC_CLERK_PROXY_URL,
domain: paramDomain || import.meta.env.PUBLIC_CLERK_DOMAIN,
publishableKey: paramPublishableKey || import.meta.env.PUBLIC_CLERK_PUBLISHABLE_KEY || '',
clerkUiUrl: paramClerkUiUrl || import.meta.env.PUBLIC_CLERK_UI_URL,
clerkUIUrl: paramClerkUIUrl || import.meta.env.PUBLIC_CLERK_UI_URL,
clerkJSUrl: paramClerkJSUrl || import.meta.env.PUBLIC_CLERK_JS_URL,
clerkJSVariant: paramClerkJSVariant || import.meta.env.PUBLIC_CLERK_JS_VARIANT,
clerkJSVersion: paramClerkJSVersion || import.meta.env.PUBLIC_CLERK_JS_VERSION,
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/server/build-clerk-hotload-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function buildClerkHotloadScript(locals: APIContext['locals']) {
publishableKey,
});
const clerkUiScriptSrc = clerkUiScriptUrl({
clerkUiUrl: getSafeEnv(locals).clerkUiUrl,
clerkUIUrl: getSafeEnv(locals).clerkUIUrl,
domain,
proxyUrl,
publishableKey,
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/server/get-safe-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function getSafeEnv(context: ContextOrLocals) {
signInUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_IN_URL', context),
signUpUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_UP_URL', context),
clerkJsUrl: getContextEnvVar('PUBLIC_CLERK_JS_URL', context),
clerkUiUrl: getContextEnvVar('PUBLIC_CLERK_UI_URL', context),
clerkUIUrl: getContextEnvVar('PUBLIC_CLERK_UI_URL', context),
clerkJsVariant: getContextEnvVar('PUBLIC_CLERK_JS_VARIANT', context) as 'headless' | '' | undefined,
clerkJsVersion: getContextEnvVar('PUBLIC_CLERK_JS_VERSION', context),
apiVersion: getContextEnvVar('CLERK_API_VERSION', context),
Expand Down
Loading
Loading