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;