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;