Skip to content

Commit 3983cf8

Browse files
authored
feat(ui): Add unified ui prop with RSC support via conditional exports (#7664)
1 parent 833b325 commit 3983cf8

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+853
-282
lines changed

.changeset/shiny-owls-dance.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@clerk/ui': minor
3+
'@clerk/react': minor
4+
'@clerk/nextjs': minor
5+
'@clerk/vue': minor
6+
'@clerk/astro': minor
7+
'@clerk/chrome-extension': minor
8+
'@clerk/shared': minor
9+
---
10+
11+
Add `ui` prop to `ClerkProvider` for passing `@clerk/ui`

integration/templates/express-vite/src/client/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { Clerk } from '@clerk/clerk-js';
2-
import { ClerkUi } from '@clerk/ui/entry';
2+
import { ClerkUI } from '@clerk/ui/entry';
33

44
const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
55

66
document.addEventListener('DOMContentLoaded', async function () {
77
const clerk = new Clerk(publishableKey);
88

99
await clerk.load({
10-
clerkUICtor: ClerkUi,
10+
ui: { ClerkUI },
1111
});
1212

1313
if (clerk.isSignedIn) {

integration/tests/next-build.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
22

33
import type { Application } from '../models/application';
44
import { appConfigs } from '../presets';
5+
import { linkPackage } from '../presets/utils';
56

67
type RenderingModeTestCase = {
78
name: string;
@@ -23,6 +24,69 @@ function getIndicator(buildOutput: string, type: 'Static' | 'Dynamic') {
2324
.split(' ')[0];
2425
}
2526

27+
test.describe('next build - bundled UI with react-server condition @nextjs', () => {
28+
test.describe.configure({ mode: 'parallel' });
29+
let app: Application;
30+
31+
test.beforeAll(async () => {
32+
test.setTimeout(90_000); // Wait for app to be ready
33+
app = await appConfigs.next.appRouter
34+
.clone()
35+
.addDependency('@clerk/ui', linkPackage('ui'))
36+
.addFile(
37+
'src/app/layout.tsx',
38+
() => `import './globals.css';
39+
import { Inter } from 'next/font/google';
40+
import { ClerkProvider } from '@clerk/nextjs';
41+
import { ui } from '@clerk/ui';
42+
43+
const inter = Inter({ subsets: ['latin'] });
44+
45+
export const metadata = {
46+
title: 'Create Next App',
47+
description: 'Generated by create next app',
48+
};
49+
50+
export default function RootLayout({ children }: { children: React.ReactNode }) {
51+
return (
52+
<ClerkProvider ui={ui}>
53+
<html lang='en'>
54+
<body className={inter.className}>{children}</body>
55+
</html>
56+
</ClerkProvider>
57+
);
58+
}
59+
`,
60+
)
61+
.commit();
62+
await app.setup();
63+
await app.withEnv(appConfigs.envs.withEmailCodes);
64+
await app.build();
65+
});
66+
67+
test.afterAll(async () => {
68+
await app.teardown();
69+
});
70+
71+
test('When ui prop is used in server component layout, builds successfully', () => {
72+
// The layout.tsx imports { ui } from "@clerk/ui" and passes ui={ui} to ClerkProvider
73+
// This tests the react-server conditional export which provides a server-safe marker
74+
// The build should succeed without errors about client-only modules in server components
75+
expect(app.buildOutput).not.toMatch(/error/i);
76+
expect(app.buildOutput).toContain('Generating static pages');
77+
});
78+
79+
test('Static pages remain static with bundled UI', () => {
80+
// Get the static indicator from the build output
81+
const staticIndicator = getIndicator(app.buildOutput, 'Static');
82+
83+
// /_not-found should still be static even with bundled UI
84+
const notFoundPageLine = app.buildOutput.split('\n').find(msg => msg.includes('/_not-found'));
85+
86+
expect(notFoundPageLine).toContain(staticIndicator);
87+
});
88+
});
89+
2690
test.describe('next build - provider as client component @nextjs', () => {
2791
test.describe.configure({ mode: 'parallel' });
2892
let app: Application;

packages/astro/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@
8686
"lint": "eslint src env.d.ts",
8787
"lint:attw": "attw --pack . --profile esm-only --ignore-rules internal-resolution-error",
8888
"lint:publint": "pnpm copy:components && publint",
89-
"publish:local": "pnpm yalc push --replace --sig"
89+
"publish:local": "pnpm yalc push --replace --sig",
90+
"test": "vitest run"
9091
},
9192
"dependencies": {
9293
"@clerk/backend": "workspace:^",

packages/astro/src/env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ interface InternalEnv {
66
readonly PUBLIC_CLERK_JS_URL?: string;
77
readonly PUBLIC_CLERK_JS_VERSION?: string;
88
readonly PUBLIC_CLERK_UI_URL?: string;
9+
readonly PUBLIC_CLERK_UI_VERSION?: string;
910
readonly PUBLIC_CLERK_PREFETCH_UI?: string;
1011
readonly CLERK_API_KEY?: string;
1112
readonly CLERK_API_URL?: string;

packages/astro/src/integration/create-integration.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ function createIntegration<Params extends HotloadAstroClerkIntegrationParams>()
2121
// These are not provided when the "bundled" integration is used
2222
const clerkJSUrl = (params as any)?.clerkJSUrl as string | undefined;
2323
const clerkJSVersion = (params as any)?.clerkJSVersion as string | undefined;
24+
const clerkUIVersion = (params as any)?.clerkUIVersion as string | undefined;
2425
const prefetchUI = (params as any)?.prefetchUI as boolean | undefined;
26+
const hasUI = !!(params as any)?.ui;
2527

2628
return {
2729
name: '@clerk/astro/integration',
@@ -57,7 +59,11 @@ function createIntegration<Params extends HotloadAstroClerkIntegrationParams>()
5759
...buildEnvVarFromOption(domain, 'PUBLIC_CLERK_DOMAIN'),
5860
...buildEnvVarFromOption(clerkJSUrl, 'PUBLIC_CLERK_JS_URL'),
5961
...buildEnvVarFromOption(clerkJSVersion, 'PUBLIC_CLERK_JS_VERSION'),
60-
...buildEnvVarFromOption(prefetchUI === false ? 'false' : undefined, 'PUBLIC_CLERK_PREFETCH_UI'),
62+
...buildEnvVarFromOption(clerkUIVersion, 'PUBLIC_CLERK_UI_VERSION'),
63+
...buildEnvVarFromOption(
64+
prefetchUI === false || hasUI ? 'false' : undefined,
65+
'PUBLIC_CLERK_PREFETCH_UI',
66+
),
6167
},
6268

6369
ssr: {
@@ -165,6 +171,7 @@ function createClerkEnvSchema() {
165171
PUBLIC_CLERK_DOMAIN: envField.string({ context: 'client', access: 'public', optional: true, url: true }),
166172
PUBLIC_CLERK_JS_URL: envField.string({ context: 'client', access: 'public', optional: true, url: true }),
167173
PUBLIC_CLERK_JS_VERSION: envField.string({ context: 'client', access: 'public', optional: true }),
174+
PUBLIC_CLERK_UI_VERSION: envField.string({ context: 'client', access: 'public', optional: true }),
168175
PUBLIC_CLERK_PREFETCH_UI: envField.string({ context: 'client', access: 'public', optional: true }),
169176
PUBLIC_CLERK_UI_URL: envField.string({ context: 'client', access: 'public', optional: true, url: true }),
170177
PUBLIC_CLERK_TELEMETRY_DISABLED: envField.boolean({ context: 'client', access: 'public', optional: true }),
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const mockLoadClerkUIScript = vi.fn();
4+
const mockLoadClerkJSScript = vi.fn();
5+
6+
vi.mock('@clerk/shared/loadClerkJsScript', () => ({
7+
loadClerkJSScript: (...args: unknown[]) => mockLoadClerkJSScript(...args),
8+
loadClerkUIScript: (...args: unknown[]) => mockLoadClerkUIScript(...args),
9+
setClerkJSLoadingErrorPackageName: vi.fn(),
10+
}));
11+
12+
// Mock nanostores
13+
vi.mock('../../stores/external', () => ({
14+
$clerkStore: { notify: vi.fn() },
15+
}));
16+
17+
vi.mock('../../stores/internal', () => ({
18+
$clerk: { get: vi.fn(), set: vi.fn() },
19+
$csrState: { setKey: vi.fn() },
20+
}));
21+
22+
vi.mock('../invoke-clerk-astro-js-functions', () => ({
23+
invokeClerkAstroJSFunctions: vi.fn(),
24+
}));
25+
26+
vi.mock('../mount-clerk-astro-js-components', () => ({
27+
mountAllClerkAstroJSComponents: vi.fn(),
28+
}));
29+
30+
const mockClerkUICtor = vi.fn();
31+
32+
describe('getClerkUIEntryChunk', () => {
33+
beforeEach(() => {
34+
vi.clearAllMocks();
35+
vi.resetModules();
36+
(window as any).__internal_ClerkUICtor = undefined;
37+
(window as any).Clerk = undefined;
38+
});
39+
40+
afterEach(() => {
41+
(window as any).__internal_ClerkUICtor = undefined;
42+
(window as any).Clerk = undefined;
43+
});
44+
45+
it('preserves clerkUIUrl from options', async () => {
46+
mockLoadClerkUIScript.mockImplementation(async () => {
47+
(window as any).__internal_ClerkUICtor = mockClerkUICtor;
48+
return null;
49+
});
50+
51+
mockLoadClerkJSScript.mockImplementation(async () => {
52+
(window as any).Clerk = {
53+
load: vi.fn().mockResolvedValue(undefined),
54+
addListener: vi.fn(),
55+
};
56+
return null;
57+
});
58+
59+
// Dynamically import to get fresh module with mocks
60+
const { createClerkInstance } = await import('../create-clerk-instance');
61+
62+
// Call createClerkInstance with clerkUIUrl
63+
await createClerkInstance({
64+
publishableKey: 'pk_test_xxx',
65+
clerkUIUrl: 'https://custom.selfhosted.example.com/ui.js',
66+
});
67+
68+
expect(mockLoadClerkUIScript).toHaveBeenCalled();
69+
const loadClerkUIScriptCall = mockLoadClerkUIScript.mock.calls[0]?.[0] as Record<string, unknown>;
70+
expect(loadClerkUIScriptCall?.clerkUIUrl).toBe('https://custom.selfhosted.example.com/ui.js');
71+
});
72+
73+
it('does not set clerkUIUrl when not provided', async () => {
74+
mockLoadClerkUIScript.mockImplementation(async () => {
75+
(window as any).__internal_ClerkUICtor = mockClerkUICtor;
76+
return null;
77+
});
78+
79+
mockLoadClerkJSScript.mockImplementation(async () => {
80+
(window as any).Clerk = {
81+
load: vi.fn().mockResolvedValue(undefined),
82+
addListener: vi.fn(),
83+
};
84+
return null;
85+
});
86+
87+
const { createClerkInstance } = await import('../create-clerk-instance');
88+
89+
await createClerkInstance({
90+
publishableKey: 'pk_test_xxx',
91+
});
92+
93+
expect(mockLoadClerkUIScript).toHaveBeenCalled();
94+
const loadClerkUIScriptCall = mockLoadClerkUIScript.mock.calls[0]?.[0] as Record<string, unknown>;
95+
expect(loadClerkUIScriptCall?.clerkUIUrl).toBeUndefined();
96+
});
97+
});

packages/astro/src/internal/create-clerk-instance.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
setClerkJSLoadingErrorPackageName,
55
} from '@clerk/shared/loadClerkJsScript';
66
import type { ClerkOptions } from '@clerk/shared/types';
7-
import type { ClerkUiConstructor } from '@clerk/shared/ui';
7+
import type { ClerkUIConstructor } from '@clerk/shared/ui';
88
import type { Ui } from '@clerk/ui/internal';
99

1010
import { $clerkStore } from '../stores/external';
@@ -40,7 +40,7 @@ async function createClerkInstanceInternal<TUi extends Ui = Ui>(options?: AstroC
4040
// Both functions return early if the scripts are already loaded
4141
// (e.g., via middleware-injected script tags in the HTML head).
4242
const clerkJsChunk = getClerkJsEntryChunk(options);
43-
const clerkUICtor = getClerkUIEntryChunk(options);
43+
const ClerkUI = getClerkUIEntryChunk(options);
4444

4545
await clerkJsChunk;
4646

@@ -59,7 +59,7 @@ async function createClerkInstanceInternal<TUi extends Ui = Ui>(options?: AstroC
5959
routerReplace: createNavigationHandler(window.history.replaceState.bind(window.history)),
6060
...options,
6161
// Pass the clerk-ui constructor promise to clerk.load()
62-
clerkUICtor,
62+
ui: { ...options?.ui, ClerkUI },
6363
} as unknown as ClerkOptions;
6464

6565
initOptions = clerkOptions;
@@ -115,13 +115,14 @@ async function getClerkJsEntryChunk<TUi extends Ui = Ui>(options?: AstroClerkCre
115115
*/
116116
async function getClerkUIEntryChunk<TUi extends Ui = Ui>(
117117
options?: AstroClerkCreateInstanceParams<TUi>,
118-
): Promise<ClerkUiConstructor | undefined> {
119-
// Honor explicit clerkUICtor even when prefetchUI=false
120-
if (options?.clerkUICtor) {
121-
return options.clerkUICtor;
118+
): Promise<ClerkUIConstructor | undefined> {
119+
// Support bundled UI via ui.ClerkUI prop
120+
if (options?.ui?.ClerkUI) {
121+
return options.ui.ClerkUI;
122122
}
123123

124-
if (options?.prefetchUI === false) {
124+
// Skip CDN prefetch when ui prop is passed (bundled UI) or prefetchUI is false
125+
if (options?.ui || options?.prefetchUI === false) {
125126
return undefined;
126127
}
127128

packages/astro/src/internal/create-injection-script-runner.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ function createInjectionScriptRunner(creator: CreateClerkInstanceInternalFn) {
2222
clientSafeVars = JSON.parse(clientSafeVarsContainer.textContent || '{}');
2323
}
2424

25-
await creator(mergeEnvVarsWithParams({ ...astroClerkOptions, ...clientSafeVars }));
25+
await creator({
26+
...mergeEnvVarsWithParams({ ...astroClerkOptions, ...clientSafeVars }),
27+
});
2628
}
2729

2830
return runner;

packages/astro/src/internal/merge-env-vars-with-params.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & { publish
3636
telemetry: paramTelemetry,
3737
clerkJSUrl: paramClerkJSUrl,
3838
clerkJSVersion: paramClerkJSVersion,
39-
clerkUIUrl: paramClerkUiUrl,
39+
clerkUIUrl: paramClerkUIUrl,
40+
clerkUIVersion: paramClerkUIVersion,
4041
prefetchUI: paramPrefetchUI,
4142
...rest
4243
} = params || {};
@@ -50,7 +51,8 @@ const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & { publish
5051
publishableKey: paramPublishableKey || import.meta.env.PUBLIC_CLERK_PUBLISHABLE_KEY || '',
5152
clerkJSUrl: paramClerkJSUrl || import.meta.env.PUBLIC_CLERK_JS_URL,
5253
clerkJSVersion: paramClerkJSVersion || import.meta.env.PUBLIC_CLERK_JS_VERSION,
53-
clerkUIUrl: paramClerkUiUrl || import.meta.env.PUBLIC_CLERK_UI_URL,
54+
clerkUIUrl: paramClerkUIUrl || import.meta.env.PUBLIC_CLERK_UI_URL,
55+
clerkUIVersion: paramClerkUIVersion || import.meta.env.PUBLIC_CLERK_UI_VERSION,
5456
prefetchUI: mergePrefetchUIConfig(paramPrefetchUI),
5557
telemetry: paramTelemetry || {
5658
disabled: isTruthy(import.meta.env.PUBLIC_CLERK_TELEMETRY_DISABLED),

0 commit comments

Comments
 (0)