Skip to content

Commit 0cca492

Browse files
authored
fix(ui,nextjs): use rsc client reference for clerk-ui instead of dynamic import (#7809)
1 parent 8479734 commit 0cca492

8 files changed

Lines changed: 64 additions & 27 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/ui': patch
3+
'@clerk/nextjs': patch
4+
---
5+
6+
Fix `@clerk/ui/entry` bare specifier failing in browser when using `ui` prop with RSC

packages/nextjs/src/app-router/client/ClerkProvider.tsx

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import { ClerkProvider as ReactClerkProvider } from '@clerk/react';
33
import type { Ui } from '@clerk/react/internal';
44
import { InitialStateProvider } from '@clerk/shared/react';
5-
import type { ClerkUIConstructor } from '@clerk/shared/ui';
65
import dynamic from 'next/dynamic';
76
import { useRouter } from 'next/navigation';
87
import React from 'react';
@@ -18,11 +17,6 @@ import { invalidateCacheAction } from '../server-actions';
1817
import { useAwaitablePush } from './useAwaitablePush';
1918
import { useAwaitableReplace } from './useAwaitableReplace';
2019

21-
// Cached promise for resolving ClerkUI constructor via dynamic import.
22-
// In RSC, the ui prop from @clerk/ui is serialized without the ClerkUI constructor
23-
// (not serializable). This re-imports it on the client when needed.
24-
let _resolvedClerkUI: Promise<ClerkUIConstructor> | undefined;
25-
2620
/**
2721
* LazyCreateKeylessApplication should only be loaded if the conditions below are met.
2822
* Note: Using lazy() with Suspense instead of dynamic is not possible as React will throw a hydration error when `ClerkProvider` wraps `<html><body>...`
@@ -91,20 +85,6 @@ const NextClientClerkProvider = <TUi extends Ui = Ui>(props: NextClerkProviderPr
9185
routerReplace: replace,
9286
});
9387

94-
// Resolve ClerkUI for RSC: when the ui prop is serialized through React Server Components,
95-
// the ClerkUI constructor is stripped (not serializable). Re-import it on the client.
96-
const uiProp = mergedProps.ui as { __brand?: string; ClerkUI?: unknown } | undefined;
97-
if (uiProp?.__brand && !uiProp?.ClerkUI) {
98-
// webpackIgnore/turbopackIgnore prevent the bundler from statically resolving @clerk/ui/entry at build time,
99-
// since @clerk/ui is an optional dependency that may not be installed.
100-
// @ts-expect-error - @clerk/ui is an optional peer dependency, not declared in this package's dependencies
101-
// eslint-disable-next-line import/no-unresolved
102-
_resolvedClerkUI ??= import(/* webpackIgnore: true */ /* turbopackIgnore: true */ '@clerk/ui/entry').then(
103-
(m: { ClerkUI: ClerkUIConstructor }) => m.ClerkUI,
104-
);
105-
mergedProps.ui = { ...mergedProps.ui, ClerkUI: _resolvedClerkUI };
106-
}
107-
10888
return (
10989
<ClerkNextOptionsProvider options={mergedProps}>
11090
<ReactClerkProvider {...mergedProps}>

packages/ui/src/ClerkUI.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
'use client';
2+
13
import { ClerkRuntimeError } from '@clerk/shared/error';
24
import { logger } from '@clerk/shared/logger';
35
import type { ModuleManager } from '@clerk/shared/moduleManager';
@@ -8,12 +10,41 @@ import { isVersionAtLeast, parseVersion } from '@clerk/shared/versionCheck';
810
import { type MountComponentRenderer, mountComponentRenderer } from './Components';
911
import { MIN_CLERK_JS_VERSION } from './constants';
1012

13+
/**
14+
* Core rendering engine for Clerk's prebuilt UI components.
15+
*
16+
* `ClerkUI` bootstraps the component renderer that powers Clerk's drop-in
17+
* authentication and user-management components (`<SignIn />`, `<UserButton />`,
18+
* etc.). It is created internally by Clerk SDKs when the `ui` prop is passed to
19+
* `ClerkProvider` and should not be instantiated directly by application code.
20+
*
21+
* This module is marked `'use client'` so that React Server Components can
22+
* serialize `ClerkUI` as a client reference rather than attempting to serialize
23+
* the class itself.
24+
*
25+
* @public
26+
*/
1127
export class ClerkUI implements ClerkUIInstance {
1228
static version = PACKAGE_VERSION;
1329
version = PACKAGE_VERSION;
1430

1531
#componentRenderer: ReturnType<MountComponentRenderer>;
1632

33+
/**
34+
* Creates a new `ClerkUI` instance and mounts the internal component renderer.
35+
*
36+
* Validates that the active `@clerk/clerk-js` version satisfies the minimum
37+
* required version ({@link MIN_CLERK_JS_VERSION}). In development instances a
38+
* mismatch throws a {@link ClerkRuntimeError}; in production it logs a warning.
39+
*
40+
* @param getClerk - Accessor that returns the active {@link Clerk} instance.
41+
* @param getEnvironment - Accessor that returns the current {@link EnvironmentResource}, or `null`/`undefined` if not yet loaded.
42+
* @param options - Global {@link ClerkOptions} forwarded to the component renderer.
43+
* @param moduleManager - The SDK's {@link ModuleManager} used for module resolution and lazy loading.
44+
* @throws {ClerkRuntimeError} When running in a development instance with an incompatible `@clerk/clerk-js` version.
45+
*
46+
* @internal
47+
*/
1748
constructor(
1849
getClerk: () => Clerk,
1950
getEnvironment: () => EnvironmentResource | null | undefined,
@@ -50,6 +81,18 @@ export class ClerkUI implements ClerkUIInstance {
5081
this.#componentRenderer = mountComponentRenderer(getClerk, getEnvironment, options, moduleManager);
5182
}
5283

84+
/**
85+
* Ensures the UI component renderer is mounted and ready.
86+
*
87+
* Returns a promise that resolves with {@link ComponentControls} once the
88+
* renderer is fully initialised. Subsequent calls return the same promise.
89+
*
90+
* @param opts - Optional hints for the renderer.
91+
* @param opts.preloadHint - An optional component name to preload assets for (e.g. `"SignIn"`).
92+
* @returns A promise resolving to {@link ComponentControls} for mounting, unmounting, and updating components.
93+
*
94+
* @public
95+
*/
5396
ensureMounted(opts?: { preloadHint?: string }): Promise<SharedComponentControls> {
5497
return this.#componentRenderer.ensureMounted(opts as unknown as any) as Promise<SharedComponentControls>;
5598
}

packages/ui/src/__tests__/__snapshots__/exports.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ exports[`module exports > default export (index.ts) > should have the expected s
1010

1111
exports[`module exports > server export (server.ts) > should have the expected shape 1`] = `
1212
[
13+
"ClerkUI",
1314
"__brand",
1415
"version",
1516
]

packages/ui/src/__tests__/exports.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ describe('module exports', () => {
3333
expect((serverUi as any).__brand).toBe('__clerkUI');
3434
});
3535

36-
it('should NOT include ClerkUI constructor', () => {
37-
expect((serverUi as any).ClerkUI).toBeUndefined();
36+
it('should include ClerkUI constructor for RSC client reference', () => {
37+
expect((serverUi as any).ClerkUI).toBeDefined();
38+
expect(typeof (serverUi as any).ClerkUI).toBe('function');
3839
});
3940

4041
it('should include version', () => {

packages/ui/src/components/SignIn/lazy-sign-up.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { lazy } from 'react';
22

3-
const preloadSignUp = () => import(/* webpackChunkName: "signUp" */ '../SignUp');
3+
const preloadSignUp = () => import(/* webpackChunkName: "signup" */ '../SignUp');
44

55
const LazySignUpVerifyPhone = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpVerifyPhone })));
66
const LazySignUpVerifyEmail = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpVerifyEmail })));
@@ -9,7 +9,7 @@ const LazySignUpSSOCallback = lazy(() => preloadSignUp().then(m => ({ default: m
99
const LazySignUpContinue = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpContinue })));
1010

1111
const lazyCompleteSignUpFlow = () =>
12-
import(/* webpackChunkName: "signUp" */ '../SignUp/util').then(m => m.completeSignUpFlow);
12+
import(/* webpackChunkName: "signup" */ '../SignUp/util').then(m => m.completeSignUpFlow);
1313

1414
export {
1515
preloadSignUp,

packages/ui/src/entry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1+
'use client';
2+
13
export { ClerkUI } from './ClerkUI';

packages/ui/src/server.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ import type { Ui } from './internal';
22
import { UI_BRAND } from './internal';
33
import type { Appearance } from './internal/appearance';
44

5+
import { ClerkUI } from './entry';
6+
57
declare const PACKAGE_VERSION: string;
68

79
/**
8-
* Server-safe UI marker for React Server Components.
10+
* UI object for React Server Components.
911
*
10-
* This export does not include the ClerkUI constructor, making it safe to import
11-
* in server components. The constructor is resolved via dynamic import when needed.
12+
* ClerkUI is imported from a 'use client' module so that RSC serializes it as a
13+
* client reference. The bundler includes the actual ClerkUI code only in the
14+
* client bundle and resolves the reference automatically on hydration.
1215
*
1316
* @example
1417
* ```tsx
@@ -24,4 +27,5 @@ declare const PACKAGE_VERSION: string;
2427
export const ui = {
2528
__brand: UI_BRAND,
2629
version: PACKAGE_VERSION,
30+
ClerkUI,
2731
} as unknown as Ui<Appearance>;

0 commit comments

Comments
 (0)