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/integration/templates/custom-flows-react-vite/src/main.tsx b/integration/templates/custom-flows-react-vite/src/main.tsx index 08bb652e4c2..225a3ddd20f 100644 --- a/integration/templates/custom-flows-react-vite/src/main.tsx +++ b/integration/templates/custom-flows-react-vite/src/main.tsx @@ -15,7 +15,7 @@ createRoot(document.getElementById('root')!).render(
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, diff --git a/integration/templates/express-vite/src/client/main.ts b/integration/templates/express-vite/src/client/main.ts index bf19f46d7b7..92df0f15660 100644 --- a/integration/templates/express-vite/src/client/main.ts +++ b/integration/templates/express-vite/src/client/main.ts @@ -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 = ` diff --git a/integration/templates/react-cra/src/index.tsx b/integration/templates/react-cra/src/index.tsx index 65271a25b7e..81039d51a20 100644 --- a/integration/templates/react-cra/src/index.tsx +++ b/integration/templates/react-cra/src/index.tsx @@ -10,7 +10,7 @@ root.render( { return ( navigate(to)} routerReplace={(to: string) => navigate(to, { replace: true })} appearance={{ diff --git a/integration/templates/tanstack-react-start/src/routes/__root.tsx b/integration/templates/tanstack-react-start/src/routes/__root.tsx index 4dd7cf9d763..b9adc012c75 100644 --- a/integration/templates/tanstack-react-start/src/routes/__root.tsx +++ b/integration/templates/tanstack-react-start/src/routes/__root.tsx @@ -29,7 +29,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { () // 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; @@ -61,7 +61,7 @@ function createIntegration() ...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'), }, 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..46dc1479c80 --- /dev/null +++ b/packages/astro/src/internal/__tests__/create-clerk-instance.test.ts @@ -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; + 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; + 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; + expect(loadClerkUiScriptCall?.clerkUIUrl).toBeUndefined(); + }); +}); diff --git a/packages/astro/src/internal/create-clerk-instance.ts b/packages/astro/src/internal/create-clerk-instance.ts index e1cbd520144..2b7de8618d1 100644 --- a/packages/astro/src/internal/create-clerk-instance.ts +++ b/packages/astro/src/internal/create-clerk-instance.ts @@ -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'; @@ -40,7 +40,7 @@ async function createClerkInstanceInternal(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; @@ -58,8 +58,12 @@ async function createClerkInstanceInternal(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; @@ -109,23 +113,33 @@ async function getClerkJsEntryChunk(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( options?: AstroClerkCreateInstanceParams, -): Promise { - if (options?.clerkUiCtor) { - return options.clerkUiCtor; +): Promise { + // 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 }; diff --git a/packages/astro/src/internal/create-injection-script-runner.ts b/packages/astro/src/internal/create-injection-script-runner.ts index e07b298edc0..3a6b035ed01 100644 --- a/packages/astro/src/internal/create-injection-script-runner.ts +++ b/packages/astro/src/internal/create-injection-script-runner.ts @@ -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; diff --git a/packages/astro/src/internal/merge-env-vars-with-params.ts b/packages/astro/src/internal/merge-env-vars-with-params.ts index dea24762b16..04740cbe66a 100644 --- a/packages/astro/src/internal/merge-env-vars-with-params.ts +++ b/packages/astro/src/internal/merge-env-vars-with-params.ts @@ -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 || {}; @@ -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, diff --git a/packages/astro/src/server/build-clerk-hotload-script.ts b/packages/astro/src/server/build-clerk-hotload-script.ts index b3cf7d37089..c60ca7028a4 100644 --- a/packages/astro/src/server/build-clerk-hotload-script.ts +++ b/packages/astro/src/server/build-clerk-hotload-script.ts @@ -19,7 +19,7 @@ function buildClerkHotloadScript(locals: APIContext['locals']) { publishableKey, }); const clerkUiScriptSrc = clerkUiScriptUrl({ - clerkUiUrl: getSafeEnv(locals).clerkUiUrl, + clerkUIUrl: getSafeEnv(locals).clerkUIUrl, domain, proxyUrl, publishableKey, diff --git a/packages/astro/src/server/get-safe-env.ts b/packages/astro/src/server/get-safe-env.ts index 7ec1824029b..5caf01d5779 100644 --- a/packages/astro/src/server/get-safe-env.ts +++ b/packages/astro/src/server/get-safe-env.ts @@ -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), diff --git a/packages/astro/src/types.ts b/packages/astro/src/types.ts index 7f0613e5968..219d57fe510 100644 --- a/packages/astro/src/types.ts +++ b/packages/astro/src/types.ts @@ -7,7 +7,7 @@ import type { ShowProps, Without, } from '@clerk/shared/types'; -import type { ClerkUiConstructor } from '@clerk/shared/ui'; +import type { ClerkUIConstructor } from '@clerk/shared/ui'; import type { Appearance, Ui } from '@clerk/ui/internal'; type AstroClerkUpdateOptions = Pick & { @@ -35,7 +35,12 @@ type AstroClerkIntegrationParams = Without< /** * The URL that `@clerk/ui` should be hot-loaded from. */ - clerkUiUrl?: string; + clerkUIUrl?: string; + /** + * The Clerk UI bundle to use. When provided with a bundled UI via + * `ui.ClerkUI`, it will be used instead of loading from CDN. + */ + ui?: TUi; }; type AstroClerkCreateInstanceParams = AstroClerkIntegrationParams & { @@ -59,7 +64,7 @@ declare global { __astro_clerk_component_props: Map>>; __astro_clerk_function_props: Map>>; Clerk: BrowserClerk; - __internal_ClerkUiCtor?: ClerkUiConstructor; + __internal_ClerkUICtor?: ClerkUIConstructor; } } 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/chrome-extension/src/react/ClerkProvider.tsx b/packages/chrome-extension/src/react/ClerkProvider.tsx index 45237484496..6b013a2e0d0 100644 --- a/packages/chrome-extension/src/react/ClerkProvider.tsx +++ b/packages/chrome-extension/src/react/ClerkProvider.tsx @@ -2,7 +2,7 @@ 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 { ui } from '@clerk/ui'; import React from 'react'; import { createClerkClient } from '../internal/clerk'; @@ -32,11 +32,14 @@ export function ClerkProvider(props: ChromeExtensionClerkPr return null; } + // Chrome extension must use bundled UI (no CDN access in extensions) + const bundledUi = { ...ui, __internal_forceBundledUI: true } as typeof ui; + return ( {children} diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 315e0822f31..baf707bfcfe 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -446,7 +446,9 @@ void (async () => { ...(componentControls.clerk.getProps() ?? {}), signInUrl: '/sign-in', signUpUrl: '/sign-up', - clerkUiCtor: window.__internal_ClerkUiCtor, + ui: { + ClerkUI: window.__internal_ClerkUICtor, + }, }); renderCurrentRoute(); updateVariables(); diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html index 924e159fcd2..4983019bdf9 100644 --- a/packages/clerk-js/sandbox/template.html +++ b/packages/clerk-js/sandbox/template.html @@ -355,7 +355,7 @@ - +