Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d3f7db8
feat(ui,react): Add shared React variant to reduce bundle size
brkalow Jan 14, 2026
41e808c
docs: Add changeset for shared React variant feature
brkalow Jan 14, 2026
b49c8a1
refactor(react): Improve shared React variant implementation
brkalow Jan 15, 2026
51b34ec
fix(clerk-js): Add missing exports for internal types and no-rhc variant
brkalow Jan 15, 2026
48c4d23
fix(ui): Fix attw check for register entry point and dedupe lockfile
brkalow Jan 15, 2026
7b07cc1
fix(clerk-js): Add false-cjs to attw ignore rules
brkalow Jan 15, 2026
c083f34
Revert "fix(clerk-js): Add false-cjs to attw ignore rules"
brkalow Jan 15, 2026
6755c82
Revert "fix(clerk-js): Add missing exports for internal types and no-…
brkalow Jan 15, 2026
55f725b
Apply suggestion from @brkalow
brkalow Jan 15, 2026
0c4718f
Apply suggestion from @brkalow
brkalow Jan 15, 2026
3a7d6b3
Apply suggestion from @brkalow
brkalow Jan 15, 2026
d7c670c
updates deps
brkalow Jan 15, 2026
6818b53
fix(react): Fix ESLint curly brace and import sort errors
brkalow Jan 16, 2026
0826067
Merge branch 'main' into brkalow/sacramento
brkalow Jan 20, 2026
83fc89f
feat(ui): Add react-dom/client to shared modules registry
brkalow Jan 21, 2026
c84b0ef
refactor(react): Derive React version bounds from package.json peerDe…
brkalow Jan 21, 2026
354950d
fix(nextjs): Pass clerkUiVariant to script URL for shared React variant
brkalow Jan 22, 2026
d9e7bcf
Merge branch 'main' into brkalow/sacramento
brkalow Jan 22, 2026
e2a5f50
refactor(react): Bundle @clerk/ui/register at build time
brkalow Jan 23, 2026
cbf2f9e
fix(react): Add missing VersionBounds type import in tsup config
brkalow Jan 23, 2026
df09090
refactor(ui): Simplify shared React externals handler with constant a…
brkalow Jan 23, 2026
2cd9f97
refactor(shared): Move version check utilities to shared package
brkalow Jan 23, 2026
e35383c
fix lint issue
brkalow Jan 23, 2026
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
17 changes: 17 additions & 0 deletions .changeset/shared-react-variant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@clerk/ui": minor
"@clerk/react": minor
"@clerk/shared": patch
---

Add shared React variant to reduce bundle size when using `@clerk/react`.

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`.

**New features:**
- `@clerk/ui/register` module: Import this to register React on `globalThis.__clerkSharedModules` for sharing with `@clerk/ui`
- `clerkUIVariant` option: Set to `'shared'` to use the shared variant (automatically detected and enabled for compatible React versions in `@clerk/react`)

**For `@clerk/react` users:** No action required. The shared variant is automatically used when your React version is compatible.

**For custom integrations:** Import `@clerk/ui/register` before loading the UI bundle, then set `clerkUIVariant: 'shared'` in your configuration.
7 changes: 6 additions & 1 deletion packages/nextjs/src/utils/clerk-script.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import {
buildClerkUiScriptAttributes,
clerkJsScriptUrl,
clerkUiScriptUrl,
IS_REACT_SHARED_VARIANT_COMPATIBLE,
} from '@clerk/react/internal';
import NextScript from 'next/script';
import React from 'react';

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

const DEFAULT_CLERK_UI_VARIANT = IS_REACT_SHARED_VARIANT_COMPATIBLE ? ('shared' as const) : ('' as const);

type ClerkScriptProps = {
scriptUrl: string;
attributes: Record<string, string>;
Expand Down Expand Up @@ -43,7 +46,8 @@ function ClerkScript(props: ClerkScriptProps) {
}

export function ClerkScripts({ router }: { router: ClerkScriptProps['router'] }) {
const { publishableKey, clerkJSUrl, clerkJSVersion, clerkJSVariant, nonce, clerkUiUrl, ui } = useClerkNextOptions();
const { publishableKey, clerkJSUrl, clerkJSVersion, clerkJSVariant, nonce, clerkUiUrl, clerkUIVariant, ui } =
useClerkNextOptions();
const { domain, proxyUrl } = useClerk();

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

return (
Expand Down
1 change: 1 addition & 0 deletions packages/react/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/*/
!/src/
!/docs/
!/build-utils/
101 changes: 101 additions & 0 deletions packages/react/build-utils/__tests__/parseVersionRange.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';

import { parseRangeToBounds, type VersionBounds } from '../parseVersionRange';

describe('parseRangeToBounds', () => {
describe('caret ranges', () => {
it('parses simple caret range', () => {
expect(parseRangeToBounds('^18.0.0')).toEqual<VersionBounds[]>([[18, 0, -1, 0]]);
});

it('parses caret range with non-zero minor', () => {
expect(parseRangeToBounds('^18.2.0')).toEqual<VersionBounds[]>([[18, 2, -1, 0]]);
});

it('parses caret range with non-zero patch', () => {
expect(parseRangeToBounds('^18.2.5')).toEqual<VersionBounds[]>([[18, 2, -1, 5]]);
});
});

describe('tilde ranges', () => {
it('parses simple tilde range', () => {
expect(parseRangeToBounds('~19.0.0')).toEqual<VersionBounds[]>([[19, 0, 0, 0]]);
});

it('parses tilde range with non-zero minor', () => {
expect(parseRangeToBounds('~19.1.0')).toEqual<VersionBounds[]>([[19, 1, 1, 0]]);
});

it('parses tilde range with non-zero patch', () => {
expect(parseRangeToBounds('~19.0.3')).toEqual<VersionBounds[]>([[19, 0, 0, 3]]);
});
});

describe('exact versions', () => {
it('treats exact version as caret range', () => {
expect(parseRangeToBounds('18.3.1')).toEqual<VersionBounds[]>([[18, 3, -1, 1]]);
});
});

describe('OR combinations', () => {
it('parses two caret ranges', () => {
expect(parseRangeToBounds('^18.0.0 || ^19.0.0')).toEqual<VersionBounds[]>([
[18, 0, -1, 0],
[19, 0, -1, 0],
]);
});

it('parses mixed caret and tilde ranges', () => {
expect(parseRangeToBounds('^18.0.0 || ~19.0.3')).toEqual<VersionBounds[]>([
[18, 0, -1, 0],
[19, 0, 0, 3],
]);
});

it('parses multiple tilde ranges', () => {
expect(parseRangeToBounds('~19.0.3 || ~19.1.4 || ~19.2.3')).toEqual<VersionBounds[]>([
[19, 0, 0, 3],
[19, 1, 1, 4],
[19, 2, 2, 3],
]);
});

it('parses complex real-world range', () => {
// This is the actual range from pnpm-workspace.yaml
expect(parseRangeToBounds('^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0')).toEqual<VersionBounds[]>([
[18, 0, -1, 0],
[19, 0, 0, 3],
[19, 1, 1, 4],
[19, 2, 2, 3],
[19, 3, 3, 0],
]);
});
});

describe('edge cases', () => {
it('handles extra whitespace', () => {
expect(parseRangeToBounds(' ^18.0.0 || ^19.0.0 ')).toEqual<VersionBounds[]>([
[18, 0, -1, 0],
[19, 0, -1, 0],
]);
});

it('returns empty array for invalid input', () => {
expect(parseRangeToBounds('invalid')).toEqual<VersionBounds[]>([]);
expect(parseRangeToBounds('')).toEqual<VersionBounds[]>([]);
});

it('skips invalid parts in OR combinations', () => {
expect(parseRangeToBounds('^18.0.0 || invalid || ^19.0.0')).toEqual<VersionBounds[]>([
[18, 0, -1, 0],
[19, 0, -1, 0],
]);
});

it('handles prerelease versions', () => {
// semver.coerce strips prerelease info
expect(parseRangeToBounds('~19.3.0-0')).toEqual<VersionBounds[]>([[19, 3, 3, 0]]);
expect(parseRangeToBounds('^19.0.0-rc.1')).toEqual<VersionBounds[]>([[19, 0, -1, 0]]);
});
});
});
46 changes: 46 additions & 0 deletions packages/react/build-utils/parseVersionRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { coerce } from 'semver';

import type { VersionBounds } from '@clerk/shared/versionCheck';

export type { VersionBounds } from '@clerk/shared/versionCheck';

/**
* Parses a semver range string (e.g., "^18.0.0 || ~19.0.3") into version bounds.
*
* Supported formats:
* - Caret ranges: ^X.Y.Z - allows any version >= X.Y.Z and < (X+1).0.0
* - Tilde ranges: ~X.Y.Z - allows any version >= X.Y.Z and < X.(Y+1).0
* - Exact versions: X.Y.Z - treated as caret range
* - OR combinations: "^18.0.0 || ~19.0.3" - multiple ranges separated by ||
*
* @param rangeStr - The semver range string to parse
* @returns Array of version bounds, one per range component
*/
export function parseRangeToBounds(rangeStr: string): VersionBounds[] {
const bounds: VersionBounds[] = [];
const parts = rangeStr.split('||').map(s => s.trim());

for (const part of parts) {
if (part.startsWith('^')) {
// Caret range: ^X.Y.Z means >= X.Y.Z and < (X+1).0.0
const ver = coerce(part.slice(1));
if (ver) {
bounds.push([ver.major, ver.minor, -1, ver.patch]);
}
} else if (part.startsWith('~')) {
// Tilde range: ~X.Y.Z means >= X.Y.Z and < X.(Y+1).0
const ver = coerce(part.slice(1));
if (ver) {
bounds.push([ver.major, ver.minor, ver.minor, ver.patch]);
}
} else {
// Exact version or other format - try to parse as caret
const ver = coerce(part);
if (ver) {
bounds.push([ver.major, ver.minor, -1, ver.patch]);
}
}
}

return bounds;
}
4 changes: 3 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@
"devDependencies": {
"@clerk/localizations": "workspace:*",
"@clerk/ui": "workspace:*",
"@types/semver": "^7.7.1"
"@types/semver": "^7.7.1",
"semver": "^7.7.1",
"yaml": "^2.8.0"
},
"peerDependencies": {
"react": "catalog:peer-react",
Expand Down
18 changes: 17 additions & 1 deletion packages/react/src/contexts/ClerkContextProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import React from 'react';
import { IsomorphicClerk } from '../isomorphicClerk';
import type { IsomorphicClerkOptions } from '../types';
import { mergeWithEnv } from '../utils';
import { IS_REACT_SHARED_VARIANT_COMPATIBLE } from '../utils/versionCheck';
import { AuthContext } from './AuthContext';
import { IsomorphicClerkContext } from './IsomorphicClerkContext';

Expand Down Expand Up @@ -114,8 +115,23 @@ export function ClerkContextProvider(props: ClerkContextProvider) {
);
}

// Default clerkUIVariant based on React version compatibility.
// Computed once at module level for optimal performance.
const DEFAULT_CLERK_UI_VARIANT = IS_REACT_SHARED_VARIANT_COMPATIBLE ? ('shared' as const) : ('' as const);

const useLoadedIsomorphicClerk = (mergedOptions: IsomorphicClerkOptions) => {
const isomorphicClerkRef = React.useRef(IsomorphicClerk.getOrCreateInstance(mergedOptions));
// Merge default clerkUIVariant with user options.
// User-provided options spread last to allow explicit overrides.
// The shared variant expects React to be provided via globalThis.__clerkSharedModules
// (set up by @clerk/ui/register import), which reduces bundle size.
const optionsWithDefaults = React.useMemo(
() => ({
clerkUIVariant: DEFAULT_CLERK_UI_VARIANT,
...mergedOptions,
}),
[mergedOptions],
);
const isomorphicClerkRef = React.useRef(IsomorphicClerk.getOrCreateInstance(optionsWithDefaults));
const [clerkStatus, setClerkStatus] = React.useState(isomorphicClerkRef.current.status);

React.useEffect(() => {
Expand Down
4 changes: 4 additions & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import './polyfills';
import './types/appearance';
// Register React on the global shared modules registry.
// This enables @clerk/ui's shared variant to use the host app's React
// instead of bundling its own copy, reducing overall bundle size.
import '@clerk/ui/register';

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

Expand Down
1 change: 1 addition & 0 deletions packages/react/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { setErrorThrowerOptions } from './errors/errorThrower';
export { MultisessionAppSupport } from './components/controlComponents';
export { useRoutingProps } from './hooks/useRoutingProps';
export { useDerivedAuth } from './hooks/useAuth';
export { IS_REACT_SHARED_VARIANT_COMPATIBLE } from './utils/versionCheck';

export {
clerkJsScriptUrl,
Expand Down
33 changes: 33 additions & 0 deletions packages/react/src/utils/versionCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { isVersionCompatible, type VersionBounds } from '@clerk/shared/versionCheck';
import React from 'react';

export {
checkVersionAgainstBounds,
isVersionCompatible,
parseVersion,
type VersionBounds,
} from '@clerk/shared/versionCheck';

declare const __CLERK_UI_SUPPORTED_REACT_BOUNDS__: VersionBounds[];

/**
* Checks if the host application's React version is compatible with @clerk/ui's shared variant.
* The shared variant expects React to be provided via globalThis.__clerkSharedModules,
* so we need to ensure the host's React version matches what @clerk/ui was built against.
*
* This function is evaluated once at module load time.
*/
function computeReactVersionCompatibility(): boolean {
try {
return isVersionCompatible(React.version, __CLERK_UI_SUPPORTED_REACT_BOUNDS__);
} catch {
// If we can't determine compatibility, fall back to non-shared variant
return false;
}
}

/**
* Whether the host React version is compatible with the shared @clerk/ui variant.
* This is computed once at module load time for optimal performance.
*/
export const IS_REACT_SHARED_VARIANT_COMPATIBLE = computeReactVersionCompatibility();
57 changes: 56 additions & 1 deletion packages/react/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,61 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

import { parse as parseYaml } from 'yaml';
import { defineConfig } from 'tsup';

import { version as clerkJsVersion } from '../clerk-js/package.json';
import { name, version } from './package.json';
import { name, version, peerDependencies } from './package.json';
import { parseRangeToBounds, type VersionBounds } from './build-utils/parseVersionRange';

/**
* Resolves the React peer dependency range from package.json.
* If it's a catalog reference (catalog:XXX), looks it up in pnpm-workspace.yaml.
* Otherwise, parses the range string directly.
*/
function getClerkUiSupportedReactBounds(): VersionBounds[] {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love the logic here, doing some iteration...

const reactPeerDep = peerDependencies.react;

let rangeStr: string;

// Check if it's a catalog reference (e.g., "catalog:peer-react")
const catalogMatch = reactPeerDep.match(/^catalog:(.+)$/);
if (catalogMatch) {
const catalogName = catalogMatch[1];

// Read the version range from pnpm-workspace.yaml
const workspaceYamlPath = resolve(__dirname, '../../pnpm-workspace.yaml');
let workspaceYaml: string;
try {
workspaceYaml = readFileSync(workspaceYamlPath, 'utf-8');
} catch (err) {
throw new Error(`[@clerk/react] Failed to read pnpm-workspace.yaml: ${err}`);
}

const workspace = parseYaml(workspaceYaml);
const catalogRange = workspace?.catalogs?.[catalogName]?.react;
if (!catalogRange) {
throw new Error(`[@clerk/react] Could not find react version in catalog "${catalogName}" in pnpm-workspace.yaml`);
}
rangeStr = catalogRange;
} else {
// Not a catalog reference - use the value directly as a version range
rangeStr = reactPeerDep;
}

const bounds = parseRangeToBounds(rangeStr);

if (bounds.length === 0) {
throw new Error(`[@clerk/react] Failed to parse any version bounds from range: ${rangeStr}`);
}

return bounds;
}

export default defineConfig(overrideOptions => {
const isWatch = !!overrideOptions.watch;
const shouldPublish = !!overrideOptions.env?.publish;
const clerkUiSupportedReactBounds = getClerkUiSupportedReactBounds();

return {
entry: {
Expand All @@ -23,11 +73,16 @@ export default defineConfig(overrideOptions => {
minify: false,
sourcemap: true,
external: ['react', 'react-dom'],
// Bundle @clerk/ui/register inline at build time so consumers don't need
// @clerk/ui as a dependency. The registration code sets up globalThis.__clerkSharedModules
// to enable @clerk/ui's shared variant to use the host app's React.
noExternal: ['@clerk/ui/register'],
define: {
PACKAGE_NAME: `"${name}"`,
PACKAGE_VERSION: `"${version}"`,
JS_PACKAGE_VERSION: `"${clerkJsVersion}"`,
__DEV__: `${isWatch}`,
__CLERK_UI_SUPPORTED_REACT_BOUNDS__: JSON.stringify(clerkUiSupportedReactBounds),
},
};
});
Loading
Loading