diff --git a/.changeset/shiny-owls-dance.md b/.changeset/shiny-owls-dance.md new file mode 100644 index 00000000000..fc4839c0112 --- /dev/null +++ b/.changeset/shiny-owls-dance.md @@ -0,0 +1,11 @@ +--- +'@clerk/ui': minor +'@clerk/react': minor +'@clerk/nextjs': minor +'@clerk/vue': minor +'@clerk/astro': minor +'@clerk/chrome-extension': minor +'@clerk/shared': minor +--- + +Add `ui` prop to `ClerkProvider` for passing `@clerk/ui` diff --git a/integration/templates/express-vite/src/client/main.ts b/integration/templates/express-vite/src/client/main.ts index 7c46c42a815..7dcc4eb0a36 100644 --- a/integration/templates/express-vite/src/client/main.ts +++ b/integration/templates/express-vite/src/client/main.ts @@ -1,5 +1,5 @@ import { Clerk } from '@clerk/clerk-js'; -import { ClerkUi } from '@clerk/ui/entry'; +import { ClerkUI } from '@clerk/ui/entry'; const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; @@ -7,7 +7,7 @@ document.addEventListener('DOMContentLoaded', async function () { const clerk = new Clerk(publishableKey); await clerk.load({ - clerkUICtor: ClerkUi, + ui: { ClerkUI }, }); if (clerk.isSignedIn) { diff --git a/integration/tests/next-build.test.ts b/integration/tests/next-build.test.ts index 055c1d631e3..ffdba908205 100644 --- a/integration/tests/next-build.test.ts +++ b/integration/tests/next-build.test.ts @@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test'; import type { Application } from '../models/application'; import { appConfigs } from '../presets'; +import { linkPackage } from '../presets/utils'; type RenderingModeTestCase = { name: string; @@ -23,6 +24,69 @@ function getIndicator(buildOutput: string, type: 'Static' | 'Dynamic') { .split(' ')[0]; } +test.describe('next build - bundled UI with react-server condition @nextjs', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + + test.beforeAll(async () => { + test.setTimeout(90_000); // Wait for app to be ready + app = await appConfigs.next.appRouter + .clone() + .addDependency('@clerk/ui', linkPackage('ui')) + .addFile( + 'src/app/layout.tsx', + () => `import './globals.css'; +import { Inter } from 'next/font/google'; +import { ClerkProvider } from '@clerk/nextjs'; +import { ui } from '@clerk/ui'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata = { + title: 'Create Next App', + description: 'Generated by create next app', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + `, + ) + .commit(); + await app.setup(); + await app.withEnv(appConfigs.envs.withEmailCodes); + await app.build(); + }); + + test.afterAll(async () => { + await app.teardown(); + }); + + test('When ui prop is used in server component layout, builds successfully', () => { + // The layout.tsx imports { ui } from "@clerk/ui" and passes ui={ui} to ClerkProvider + // This tests the react-server conditional export which provides a server-safe marker + // The build should succeed without errors about client-only modules in server components + expect(app.buildOutput).not.toMatch(/error/i); + expect(app.buildOutput).toContain('Generating static pages'); + }); + + test('Static pages remain static with bundled UI', () => { + // Get the static indicator from the build output + const staticIndicator = getIndicator(app.buildOutput, 'Static'); + + // /_not-found should still be static even with bundled UI + const notFoundPageLine = app.buildOutput.split('\n').find(msg => msg.includes('/_not-found')); + + expect(notFoundPageLine).toContain(staticIndicator); + }); +}); + test.describe('next build - provider as client component @nextjs', () => { test.describe.configure({ mode: 'parallel' }); let app: Application; diff --git a/packages/astro/package.json b/packages/astro/package.json index 24a5fd9641b..86a88fd1341 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/env.d.ts b/packages/astro/src/env.d.ts index 1240941e1e2..933fc2aea3f 100644 --- a/packages/astro/src/env.d.ts +++ b/packages/astro/src/env.d.ts @@ -6,6 +6,7 @@ interface InternalEnv { readonly PUBLIC_CLERK_JS_URL?: string; readonly PUBLIC_CLERK_JS_VERSION?: string; readonly PUBLIC_CLERK_UI_URL?: string; + readonly PUBLIC_CLERK_UI_VERSION?: string; readonly PUBLIC_CLERK_PREFETCH_UI?: string; readonly CLERK_API_KEY?: string; readonly CLERK_API_URL?: string; diff --git a/packages/astro/src/integration/create-integration.ts b/packages/astro/src/integration/create-integration.ts index fc19a8371c3..753ecd2b325 100644 --- a/packages/astro/src/integration/create-integration.ts +++ b/packages/astro/src/integration/create-integration.ts @@ -21,6 +21,7 @@ function createIntegration() // These are not provided when the "bundled" integration is used const clerkJSUrl = (params as any)?.clerkJSUrl as string | undefined; const clerkJSVersion = (params as any)?.clerkJSVersion as string | undefined; + const clerkUIVersion = (params as any)?.clerkUIVersion as string | undefined; const prefetchUI = (params as any)?.prefetchUI as boolean | undefined; return { @@ -57,6 +58,7 @@ function createIntegration() ...buildEnvVarFromOption(domain, 'PUBLIC_CLERK_DOMAIN'), ...buildEnvVarFromOption(clerkJSUrl, 'PUBLIC_CLERK_JS_URL'), ...buildEnvVarFromOption(clerkJSVersion, 'PUBLIC_CLERK_JS_VERSION'), + ...buildEnvVarFromOption(clerkUIVersion, 'PUBLIC_CLERK_UI_VERSION'), ...buildEnvVarFromOption(prefetchUI === false ? 'false' : undefined, 'PUBLIC_CLERK_PREFETCH_UI'), }, @@ -165,6 +167,7 @@ function createClerkEnvSchema() { PUBLIC_CLERK_DOMAIN: envField.string({ context: 'client', access: 'public', optional: true, url: true }), PUBLIC_CLERK_JS_URL: envField.string({ context: 'client', access: 'public', optional: true, url: true }), PUBLIC_CLERK_JS_VERSION: envField.string({ context: 'client', access: 'public', optional: true }), + PUBLIC_CLERK_UI_VERSION: envField.string({ context: 'client', access: 'public', optional: true }), PUBLIC_CLERK_PREFETCH_UI: envField.string({ context: 'client', access: 'public', optional: true }), PUBLIC_CLERK_UI_URL: envField.string({ context: 'client', access: 'public', optional: true, url: true }), PUBLIC_CLERK_TELEMETRY_DISABLED: envField.boolean({ context: 'client', access: 'public', optional: true }), 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-clerk-instance.ts b/packages/astro/src/internal/create-clerk-instance.ts index 675e405382c..8365746503a 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; @@ -59,7 +59,7 @@ async function createClerkInstanceInternal(options?: AstroC routerReplace: createNavigationHandler(window.history.replaceState.bind(window.history)), ...options, // Pass the clerk-ui constructor promise to clerk.load() - clerkUICtor, + ui: { ...options?.ui, ClerkUI }, } as unknown as ClerkOptions; initOptions = clerkOptions; @@ -115,10 +115,10 @@ async function getClerkJsEntryChunk(options?: AstroClerkCre */ async function getClerkUIEntryChunk( options?: AstroClerkCreateInstanceParams, -): Promise { - // Honor explicit clerkUICtor even when prefetchUI=false - if (options?.clerkUICtor) { - return options.clerkUICtor; +): Promise { + // Support bundled UI via ui.ClerkUI prop + if (options?.ui?.ClerkUI) { + return options.ui.ClerkUI; } if (options?.prefetchUI === false) { 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/src/internal/merge-env-vars-with-params.ts b/packages/astro/src/internal/merge-env-vars-with-params.ts index 1cac532cc6f..bcecc86d790 100644 --- a/packages/astro/src/internal/merge-env-vars-with-params.ts +++ b/packages/astro/src/internal/merge-env-vars-with-params.ts @@ -36,7 +36,8 @@ const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & { publish telemetry: paramTelemetry, clerkJSUrl: paramClerkJSUrl, clerkJSVersion: paramClerkJSVersion, - clerkUIUrl: paramClerkUiUrl, + clerkUIUrl: paramClerkUIUrl, + clerkUIVersion: paramClerkUIVersion, prefetchUI: paramPrefetchUI, ...rest } = params || {}; @@ -50,7 +51,8 @@ const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & { publish publishableKey: paramPublishableKey || import.meta.env.PUBLIC_CLERK_PUBLISHABLE_KEY || '', clerkJSUrl: paramClerkJSUrl || import.meta.env.PUBLIC_CLERK_JS_URL, clerkJSVersion: paramClerkJSVersion || import.meta.env.PUBLIC_CLERK_JS_VERSION, - clerkUIUrl: paramClerkUiUrl || import.meta.env.PUBLIC_CLERK_UI_URL, + clerkUIUrl: paramClerkUIUrl || import.meta.env.PUBLIC_CLERK_UI_URL, + clerkUIVersion: paramClerkUIVersion || import.meta.env.PUBLIC_CLERK_UI_VERSION, prefetchUI: mergePrefetchUIConfig(paramPrefetchUI), telemetry: paramTelemetry || { disabled: isTruthy(import.meta.env.PUBLIC_CLERK_TELEMETRY_DISABLED), diff --git a/packages/astro/src/server/build-clerk-hotload-script.ts b/packages/astro/src/server/build-clerk-hotload-script.ts index 2ab079b908f..5510759b387 100644 --- a/packages/astro/src/server/build-clerk-hotload-script.ts +++ b/packages/astro/src/server/build-clerk-hotload-script.ts @@ -34,8 +34,9 @@ function buildClerkHotloadScript(locals: APIContext['locals']) { return clerkJsScript + '\n'; } - const clerkUiScriptSrc = clerkUIScriptUrl({ + const clerkUIScriptSrc = clerkUIScriptUrl({ clerkUIUrl: env.clerkUIUrl, + clerkUIVersion: env.clerkUIVersion, domain, proxyUrl, publishableKey, @@ -47,14 +48,14 @@ function buildClerkHotloadScript(locals: APIContext['locals']) { // registration (which happens when React code runs @clerk/ui/register). // When loadClerkUIScript() later adds a