diff --git a/.changeset/fix-ui-rsc-client-reference.md b/.changeset/fix-ui-rsc-client-reference.md new file mode 100644 index 00000000000..84ab2babc74 --- /dev/null +++ b/.changeset/fix-ui-rsc-client-reference.md @@ -0,0 +1,6 @@ +--- +'@clerk/ui': patch +'@clerk/nextjs': patch +--- + +Fix `@clerk/ui/entry` bare specifier failing in browser when using `ui` prop with RSC diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index defc42f2f6e..3177c997026 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -2,7 +2,6 @@ import { ClerkProvider as ReactClerkProvider } from '@clerk/react'; import type { Ui } from '@clerk/react/internal'; import { InitialStateProvider } from '@clerk/shared/react'; -import type { ClerkUIConstructor } from '@clerk/shared/ui'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/navigation'; import React from 'react'; @@ -18,11 +17,6 @@ import { invalidateCacheAction } from '../server-actions'; import { useAwaitablePush } from './useAwaitablePush'; import { useAwaitableReplace } from './useAwaitableReplace'; -// Cached promise for resolving ClerkUI constructor via dynamic import. -// In RSC, the ui prop from @clerk/ui is serialized without the ClerkUI constructor -// (not serializable). This re-imports it on the client when needed. -let _resolvedClerkUI: Promise | undefined; - /** * LazyCreateKeylessApplication should only be loaded if the conditions below are met. * Note: Using lazy() with Suspense instead of dynamic is not possible as React will throw a hydration error when `ClerkProvider` wraps `...` @@ -91,20 +85,6 @@ const NextClientClerkProvider = (props: NextClerkProviderPr routerReplace: replace, }); - // Resolve ClerkUI for RSC: when the ui prop is serialized through React Server Components, - // the ClerkUI constructor is stripped (not serializable). Re-import it on the client. - const uiProp = mergedProps.ui as { __brand?: string; ClerkUI?: unknown } | undefined; - if (uiProp?.__brand && !uiProp?.ClerkUI) { - // webpackIgnore/turbopackIgnore prevent the bundler from statically resolving @clerk/ui/entry at build time, - // since @clerk/ui is an optional dependency that may not be installed. - // @ts-expect-error - @clerk/ui is an optional peer dependency, not declared in this package's dependencies - // eslint-disable-next-line import/no-unresolved - _resolvedClerkUI ??= import(/* webpackIgnore: true */ /* turbopackIgnore: true */ '@clerk/ui/entry').then( - (m: { ClerkUI: ClerkUIConstructor }) => m.ClerkUI, - ); - mergedProps.ui = { ...mergedProps.ui, ClerkUI: _resolvedClerkUI }; - } - return ( diff --git a/packages/ui/src/ClerkUI.ts b/packages/ui/src/ClerkUI.ts index f76d1b49d23..c6bee5da754 100644 --- a/packages/ui/src/ClerkUI.ts +++ b/packages/ui/src/ClerkUI.ts @@ -1,3 +1,5 @@ +'use client'; + import { ClerkRuntimeError } from '@clerk/shared/error'; import { logger } from '@clerk/shared/logger'; import type { ModuleManager } from '@clerk/shared/moduleManager'; @@ -8,12 +10,41 @@ import { isVersionAtLeast, parseVersion } from '@clerk/shared/versionCheck'; import { type MountComponentRenderer, mountComponentRenderer } from './Components'; import { MIN_CLERK_JS_VERSION } from './constants'; +/** + * Core rendering engine for Clerk's prebuilt UI components. + * + * `ClerkUI` bootstraps the component renderer that powers Clerk's drop-in + * authentication and user-management components (``, ``, + * etc.). It is created internally by Clerk SDKs when the `ui` prop is passed to + * `ClerkProvider` and should not be instantiated directly by application code. + * + * This module is marked `'use client'` so that React Server Components can + * serialize `ClerkUI` as a client reference rather than attempting to serialize + * the class itself. + * + * @public + */ export class ClerkUI implements ClerkUIInstance { static version = PACKAGE_VERSION; version = PACKAGE_VERSION; #componentRenderer: ReturnType; + /** + * Creates a new `ClerkUI` instance and mounts the internal component renderer. + * + * Validates that the active `@clerk/clerk-js` version satisfies the minimum + * required version ({@link MIN_CLERK_JS_VERSION}). In development instances a + * mismatch throws a {@link ClerkRuntimeError}; in production it logs a warning. + * + * @param getClerk - Accessor that returns the active {@link Clerk} instance. + * @param getEnvironment - Accessor that returns the current {@link EnvironmentResource}, or `null`/`undefined` if not yet loaded. + * @param options - Global {@link ClerkOptions} forwarded to the component renderer. + * @param moduleManager - The SDK's {@link ModuleManager} used for module resolution and lazy loading. + * @throws {ClerkRuntimeError} When running in a development instance with an incompatible `@clerk/clerk-js` version. + * + * @internal + */ constructor( getClerk: () => Clerk, getEnvironment: () => EnvironmentResource | null | undefined, @@ -50,6 +81,18 @@ export class ClerkUI implements ClerkUIInstance { this.#componentRenderer = mountComponentRenderer(getClerk, getEnvironment, options, moduleManager); } + /** + * Ensures the UI component renderer is mounted and ready. + * + * Returns a promise that resolves with {@link ComponentControls} once the + * renderer is fully initialised. Subsequent calls return the same promise. + * + * @param opts - Optional hints for the renderer. + * @param opts.preloadHint - An optional component name to preload assets for (e.g. `"SignIn"`). + * @returns A promise resolving to {@link ComponentControls} for mounting, unmounting, and updating components. + * + * @public + */ ensureMounted(opts?: { preloadHint?: string }): Promise { return this.#componentRenderer.ensureMounted(opts as unknown as any) as Promise; } diff --git a/packages/ui/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/ui/src/__tests__/__snapshots__/exports.test.ts.snap index b85c91a002b..2bfd982ef77 100644 --- a/packages/ui/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/ui/src/__tests__/__snapshots__/exports.test.ts.snap @@ -10,6 +10,7 @@ exports[`module exports > default export (index.ts) > should have the expected s exports[`module exports > server export (server.ts) > should have the expected shape 1`] = ` [ + "ClerkUI", "__brand", "version", ] diff --git a/packages/ui/src/__tests__/exports.test.ts b/packages/ui/src/__tests__/exports.test.ts index 356d45ff750..c3a1c8f48e2 100644 --- a/packages/ui/src/__tests__/exports.test.ts +++ b/packages/ui/src/__tests__/exports.test.ts @@ -33,8 +33,9 @@ describe('module exports', () => { expect((serverUi as any).__brand).toBe('__clerkUI'); }); - it('should NOT include ClerkUI constructor', () => { - expect((serverUi as any).ClerkUI).toBeUndefined(); + it('should include ClerkUI constructor for RSC client reference', () => { + expect((serverUi as any).ClerkUI).toBeDefined(); + expect(typeof (serverUi as any).ClerkUI).toBe('function'); }); it('should include version', () => { diff --git a/packages/ui/src/components/SignIn/lazy-sign-up.ts b/packages/ui/src/components/SignIn/lazy-sign-up.ts index e3c08113fd9..536604baa6b 100644 --- a/packages/ui/src/components/SignIn/lazy-sign-up.ts +++ b/packages/ui/src/components/SignIn/lazy-sign-up.ts @@ -1,6 +1,6 @@ import { lazy } from 'react'; -const preloadSignUp = () => import(/* webpackChunkName: "signUp" */ '../SignUp'); +const preloadSignUp = () => import(/* webpackChunkName: "signup" */ '../SignUp'); const LazySignUpVerifyPhone = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpVerifyPhone }))); const LazySignUpVerifyEmail = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpVerifyEmail }))); @@ -9,7 +9,7 @@ const LazySignUpSSOCallback = lazy(() => preloadSignUp().then(m => ({ default: m const LazySignUpContinue = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpContinue }))); const lazyCompleteSignUpFlow = () => - import(/* webpackChunkName: "signUp" */ '../SignUp/util').then(m => m.completeSignUpFlow); + import(/* webpackChunkName: "signup" */ '../SignUp/util').then(m => m.completeSignUpFlow); export { preloadSignUp, diff --git a/packages/ui/src/entry.ts b/packages/ui/src/entry.ts index aed40216850..1188b232afb 100644 --- a/packages/ui/src/entry.ts +++ b/packages/ui/src/entry.ts @@ -1 +1,3 @@ +'use client'; + export { ClerkUI } from './ClerkUI'; diff --git a/packages/ui/src/server.ts b/packages/ui/src/server.ts index 530e0c1d259..f7281c15251 100644 --- a/packages/ui/src/server.ts +++ b/packages/ui/src/server.ts @@ -2,13 +2,16 @@ import type { Ui } from './internal'; import { UI_BRAND } from './internal'; import type { Appearance } from './internal/appearance'; +import { ClerkUI } from './entry'; + declare const PACKAGE_VERSION: string; /** - * Server-safe UI marker for React Server Components. + * UI object for React Server Components. * - * This export does not include the ClerkUI constructor, making it safe to import - * in server components. The constructor is resolved via dynamic import when needed. + * ClerkUI is imported from a 'use client' module so that RSC serializes it as a + * client reference. The bundler includes the actual ClerkUI code only in the + * client bundle and resolves the reference automatically on hydration. * * @example * ```tsx @@ -24,4 +27,5 @@ declare const PACKAGE_VERSION: string; export const ui = { __brand: UI_BRAND, version: PACKAGE_VERSION, + ClerkUI, } as unknown as Ui;