Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-ui-rsc-client-reference.md
Original file line number Diff line number Diff line change
@@ -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
20 changes: 0 additions & 20 deletions packages/nextjs/src/app-router/client/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<ClerkUIConstructor> | 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 `<html><body>...`
Expand Down Expand Up @@ -91,20 +85,6 @@ const NextClientClerkProvider = <TUi extends Ui = Ui>(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 (
<ClerkNextOptionsProvider options={mergedProps}>
<ReactClerkProvider {...mergedProps}>
Expand Down
43 changes: 43 additions & 0 deletions packages/ui/src/ClerkUI.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (`<SignIn />`, `<UserButton />`,
* 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<MountComponentRenderer>;

/**
* 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,
Expand Down Expand Up @@ -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<SharedComponentControls> {
return this.#componentRenderer.ensureMounted(opts as unknown as any) as Promise<SharedComponentControls>;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/__tests__/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/components/SignIn/lazy-sign-up.ts
Original file line number Diff line number Diff line change
@@ -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 })));
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/entry.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
'use client';

export { ClerkUI } from './ClerkUI';
10 changes: 7 additions & 3 deletions packages/ui/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,4 +27,5 @@ declare const PACKAGE_VERSION: string;
export const ui = {
__brand: UI_BRAND,
version: PACKAGE_VERSION,
ClerkUI,
} as unknown as Ui<Appearance>;
Loading