Skip to content

Commit d8bbc66

Browse files
brkalowclaude
andauthored
feat(ui,react): Add shared React variant to reduce bundle size (#7601)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 07f66f0 commit d8bbc66

22 files changed

Lines changed: 681 additions & 9 deletions

.changeset/shared-react-variant.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@clerk/ui": minor
3+
"@clerk/react": minor
4+
"@clerk/shared": patch
5+
---
6+
7+
Add shared React variant to reduce bundle size when using `@clerk/react`.
8+
9+
Introduces a new `ui.shared.browser.js` build variant that externalizes React dependencies, allowing the host application's React to be reused instead of bundling a separate copy. This can significantly reduce bundle size for applications using `@clerk/react`.
10+
11+
**New features:**
12+
- `@clerk/ui/register` module: Import this to register React on `globalThis.__clerkSharedModules` for sharing with `@clerk/ui`
13+
- `clerkUIVariant` option: Set to `'shared'` to use the shared variant (automatically detected and enabled for compatible React versions in `@clerk/react`)
14+
15+
**For `@clerk/react` users:** No action required. The shared variant is automatically used when your React version is compatible.
16+
17+
**For custom integrations:** Import `@clerk/ui/register` before loading the UI bundle, then set `clerkUIVariant: 'shared'` in your configuration.

packages/nextjs/src/utils/clerk-script.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import {
44
buildClerkUiScriptAttributes,
55
clerkJsScriptUrl,
66
clerkUiScriptUrl,
7+
IS_REACT_SHARED_VARIANT_COMPATIBLE,
78
} from '@clerk/react/internal';
89
import NextScript from 'next/script';
910
import React from 'react';
1011

1112
import { useClerkNextOptions } from '../client-boundary/NextOptionsContext';
1213

14+
const DEFAULT_CLERK_UI_VARIANT = IS_REACT_SHARED_VARIANT_COMPATIBLE ? ('shared' as const) : ('' as const);
15+
1316
type ClerkScriptProps = {
1417
scriptUrl: string;
1518
attributes: Record<string, string>;
@@ -43,7 +46,8 @@ function ClerkScript(props: ClerkScriptProps) {
4346
}
4447

4548
export function ClerkScripts({ router }: { router: ClerkScriptProps['router'] }) {
46-
const { publishableKey, clerkJSUrl, clerkJSVersion, clerkJSVariant, nonce, clerkUiUrl, ui } = useClerkNextOptions();
49+
const { publishableKey, clerkJSUrl, clerkJSVersion, clerkJSVariant, nonce, clerkUiUrl, clerkUIVariant, ui } =
50+
useClerkNextOptions();
4751
const { domain, proxyUrl } = useClerk();
4852

4953
if (!publishableKey) {
@@ -60,6 +64,7 @@ export function ClerkScripts({ router }: { router: ClerkScriptProps['router'] })
6064
proxyUrl,
6165
clerkUiVersion: ui?.version,
6266
clerkUiUrl: ui?.url || clerkUiUrl,
67+
clerkUIVariant: clerkUIVariant ?? DEFAULT_CLERK_UI_VARIANT,
6368
};
6469

6570
return (

packages/react/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
/*/
22
!/src/
33
!/docs/
4+
!/build-utils/
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { parseRangeToBounds, type VersionBounds } from '../parseVersionRange';
4+
5+
describe('parseRangeToBounds', () => {
6+
describe('caret ranges', () => {
7+
it('parses simple caret range', () => {
8+
expect(parseRangeToBounds('^18.0.0')).toEqual<VersionBounds[]>([[18, 0, -1, 0]]);
9+
});
10+
11+
it('parses caret range with non-zero minor', () => {
12+
expect(parseRangeToBounds('^18.2.0')).toEqual<VersionBounds[]>([[18, 2, -1, 0]]);
13+
});
14+
15+
it('parses caret range with non-zero patch', () => {
16+
expect(parseRangeToBounds('^18.2.5')).toEqual<VersionBounds[]>([[18, 2, -1, 5]]);
17+
});
18+
});
19+
20+
describe('tilde ranges', () => {
21+
it('parses simple tilde range', () => {
22+
expect(parseRangeToBounds('~19.0.0')).toEqual<VersionBounds[]>([[19, 0, 0, 0]]);
23+
});
24+
25+
it('parses tilde range with non-zero minor', () => {
26+
expect(parseRangeToBounds('~19.1.0')).toEqual<VersionBounds[]>([[19, 1, 1, 0]]);
27+
});
28+
29+
it('parses tilde range with non-zero patch', () => {
30+
expect(parseRangeToBounds('~19.0.3')).toEqual<VersionBounds[]>([[19, 0, 0, 3]]);
31+
});
32+
});
33+
34+
describe('exact versions', () => {
35+
it('treats exact version as caret range', () => {
36+
expect(parseRangeToBounds('18.3.1')).toEqual<VersionBounds[]>([[18, 3, -1, 1]]);
37+
});
38+
});
39+
40+
describe('OR combinations', () => {
41+
it('parses two caret ranges', () => {
42+
expect(parseRangeToBounds('^18.0.0 || ^19.0.0')).toEqual<VersionBounds[]>([
43+
[18, 0, -1, 0],
44+
[19, 0, -1, 0],
45+
]);
46+
});
47+
48+
it('parses mixed caret and tilde ranges', () => {
49+
expect(parseRangeToBounds('^18.0.0 || ~19.0.3')).toEqual<VersionBounds[]>([
50+
[18, 0, -1, 0],
51+
[19, 0, 0, 3],
52+
]);
53+
});
54+
55+
it('parses multiple tilde ranges', () => {
56+
expect(parseRangeToBounds('~19.0.3 || ~19.1.4 || ~19.2.3')).toEqual<VersionBounds[]>([
57+
[19, 0, 0, 3],
58+
[19, 1, 1, 4],
59+
[19, 2, 2, 3],
60+
]);
61+
});
62+
63+
it('parses complex real-world range', () => {
64+
// This is the actual range from pnpm-workspace.yaml
65+
expect(parseRangeToBounds('^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0')).toEqual<VersionBounds[]>([
66+
[18, 0, -1, 0],
67+
[19, 0, 0, 3],
68+
[19, 1, 1, 4],
69+
[19, 2, 2, 3],
70+
[19, 3, 3, 0],
71+
]);
72+
});
73+
});
74+
75+
describe('edge cases', () => {
76+
it('handles extra whitespace', () => {
77+
expect(parseRangeToBounds(' ^18.0.0 || ^19.0.0 ')).toEqual<VersionBounds[]>([
78+
[18, 0, -1, 0],
79+
[19, 0, -1, 0],
80+
]);
81+
});
82+
83+
it('returns empty array for invalid input', () => {
84+
expect(parseRangeToBounds('invalid')).toEqual<VersionBounds[]>([]);
85+
expect(parseRangeToBounds('')).toEqual<VersionBounds[]>([]);
86+
});
87+
88+
it('skips invalid parts in OR combinations', () => {
89+
expect(parseRangeToBounds('^18.0.0 || invalid || ^19.0.0')).toEqual<VersionBounds[]>([
90+
[18, 0, -1, 0],
91+
[19, 0, -1, 0],
92+
]);
93+
});
94+
95+
it('handles prerelease versions', () => {
96+
// semver.coerce strips prerelease info
97+
expect(parseRangeToBounds('~19.3.0-0')).toEqual<VersionBounds[]>([[19, 3, 3, 0]]);
98+
expect(parseRangeToBounds('^19.0.0-rc.1')).toEqual<VersionBounds[]>([[19, 0, -1, 0]]);
99+
});
100+
});
101+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { coerce } from 'semver';
2+
3+
import type { VersionBounds } from '@clerk/shared/versionCheck';
4+
5+
export type { VersionBounds } from '@clerk/shared/versionCheck';
6+
7+
/**
8+
* Parses a semver range string (e.g., "^18.0.0 || ~19.0.3") into version bounds.
9+
*
10+
* Supported formats:
11+
* - Caret ranges: ^X.Y.Z - allows any version >= X.Y.Z and < (X+1).0.0
12+
* - Tilde ranges: ~X.Y.Z - allows any version >= X.Y.Z and < X.(Y+1).0
13+
* - Exact versions: X.Y.Z - treated as caret range
14+
* - OR combinations: "^18.0.0 || ~19.0.3" - multiple ranges separated by ||
15+
*
16+
* @param rangeStr - The semver range string to parse
17+
* @returns Array of version bounds, one per range component
18+
*/
19+
export function parseRangeToBounds(rangeStr: string): VersionBounds[] {
20+
const bounds: VersionBounds[] = [];
21+
const parts = rangeStr.split('||').map(s => s.trim());
22+
23+
for (const part of parts) {
24+
if (part.startsWith('^')) {
25+
// Caret range: ^X.Y.Z means >= X.Y.Z and < (X+1).0.0
26+
const ver = coerce(part.slice(1));
27+
if (ver) {
28+
bounds.push([ver.major, ver.minor, -1, ver.patch]);
29+
}
30+
} else if (part.startsWith('~')) {
31+
// Tilde range: ~X.Y.Z means >= X.Y.Z and < X.(Y+1).0
32+
const ver = coerce(part.slice(1));
33+
if (ver) {
34+
bounds.push([ver.major, ver.minor, ver.minor, ver.patch]);
35+
}
36+
} else {
37+
// Exact version or other format - try to parse as caret
38+
const ver = coerce(part);
39+
if (ver) {
40+
bounds.push([ver.major, ver.minor, -1, ver.patch]);
41+
}
42+
}
43+
}
44+
45+
return bounds;
46+
}

packages/react/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@
101101
"devDependencies": {
102102
"@clerk/localizations": "workspace:*",
103103
"@clerk/ui": "workspace:*",
104-
"@types/semver": "^7.7.1"
104+
"@types/semver": "^7.7.1",
105+
"semver": "^7.7.1",
106+
"yaml": "^2.8.0"
105107
},
106108
"peerDependencies": {
107109
"react": "catalog:peer-react",

packages/react/src/contexts/ClerkContextProvider.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import React from 'react';
1212
import { IsomorphicClerk } from '../isomorphicClerk';
1313
import type { IsomorphicClerkOptions } from '../types';
1414
import { mergeWithEnv } from '../utils';
15+
import { IS_REACT_SHARED_VARIANT_COMPATIBLE } from '../utils/versionCheck';
1516
import { AuthContext } from './AuthContext';
1617
import { IsomorphicClerkContext } from './IsomorphicClerkContext';
1718

@@ -114,8 +115,23 @@ export function ClerkContextProvider(props: ClerkContextProvider) {
114115
);
115116
}
116117

118+
// Default clerkUIVariant based on React version compatibility.
119+
// Computed once at module level for optimal performance.
120+
const DEFAULT_CLERK_UI_VARIANT = IS_REACT_SHARED_VARIANT_COMPATIBLE ? ('shared' as const) : ('' as const);
121+
117122
const useLoadedIsomorphicClerk = (mergedOptions: IsomorphicClerkOptions) => {
118-
const isomorphicClerkRef = React.useRef(IsomorphicClerk.getOrCreateInstance(mergedOptions));
123+
// Merge default clerkUIVariant with user options.
124+
// User-provided options spread last to allow explicit overrides.
125+
// The shared variant expects React to be provided via globalThis.__clerkSharedModules
126+
// (set up by @clerk/ui/register import), which reduces bundle size.
127+
const optionsWithDefaults = React.useMemo(
128+
() => ({
129+
clerkUIVariant: DEFAULT_CLERK_UI_VARIANT,
130+
...mergedOptions,
131+
}),
132+
[mergedOptions],
133+
);
134+
const isomorphicClerkRef = React.useRef(IsomorphicClerk.getOrCreateInstance(optionsWithDefaults));
119135
const [clerkStatus, setClerkStatus] = React.useState(isomorphicClerkRef.current.status);
120136

121137
React.useEffect(() => {

packages/react/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import './polyfills';
22
import './types/appearance';
3+
// Register React on the global shared modules registry.
4+
// This enables @clerk/ui's shared variant to use the host app's React
5+
// instead of bundling its own copy, reducing overall bundle size.
6+
import '@clerk/ui/register';
37

48
import { setClerkJsLoadingErrorPackageName } from '@clerk/shared/loadClerkJsScript';
59

packages/react/src/internal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export { setErrorThrowerOptions } from './errors/errorThrower';
22
export { MultisessionAppSupport } from './components/controlComponents';
33
export { useRoutingProps } from './hooks/useRoutingProps';
44
export { useDerivedAuth } from './hooks/useAuth';
5+
export { IS_REACT_SHARED_VARIANT_COMPATIBLE } from './utils/versionCheck';
56

67
export {
78
clerkJsScriptUrl,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { isVersionCompatible, type VersionBounds } from '@clerk/shared/versionCheck';
2+
import React from 'react';
3+
4+
export {
5+
checkVersionAgainstBounds,
6+
isVersionCompatible,
7+
parseVersion,
8+
type VersionBounds,
9+
} from '@clerk/shared/versionCheck';
10+
11+
declare const __CLERK_UI_SUPPORTED_REACT_BOUNDS__: VersionBounds[];
12+
13+
/**
14+
* Checks if the host application's React version is compatible with @clerk/ui's shared variant.
15+
* The shared variant expects React to be provided via globalThis.__clerkSharedModules,
16+
* so we need to ensure the host's React version matches what @clerk/ui was built against.
17+
*
18+
* This function is evaluated once at module load time.
19+
*/
20+
function computeReactVersionCompatibility(): boolean {
21+
try {
22+
return isVersionCompatible(React.version, __CLERK_UI_SUPPORTED_REACT_BOUNDS__);
23+
} catch {
24+
// If we can't determine compatibility, fall back to non-shared variant
25+
return false;
26+
}
27+
}
28+
29+
/**
30+
* Whether the host React version is compatible with the shared @clerk/ui variant.
31+
* This is computed once at module load time for optimal performance.
32+
*/
33+
export const IS_REACT_SHARED_VARIANT_COMPATIBLE = computeReactVersionCompatibility();

0 commit comments

Comments
 (0)