Skip to content
Open
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
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.
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 @@ -11,6 +11,7 @@ import React from 'react';

import { IsomorphicClerk } from '../isomorphicClerk';
import type { IsomorphicClerkOptions } from '../types';
import { IS_REACT_SHARED_VARIANT_COMPATIBLE } from '../utils/versionCheck';
import { AuthContext } from './AuthContext';
import { IsomorphicClerkContext } from './IsomorphicClerkContext';

Expand Down Expand Up @@ -111,8 +112,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 = (options: IsomorphicClerkOptions) => {
const isomorphicClerkRef = React.useRef(IsomorphicClerk.getOrCreateInstance(options));
// 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,
...options,
}),
[options],
);
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
144 changes: 144 additions & 0 deletions packages/react/src/utils/__tests__/versionCheck.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';

import { checkVersionAgainstBounds, isVersionCompatible, parseVersion, type VersionBounds } from '../versionCheck';

describe('parseVersion', () => {
it('parses standard semver versions', () => {
expect(parseVersion('18.3.1')).toEqual({ major: 18, minor: 3, patch: 1 });
expect(parseVersion('19.0.0')).toEqual({ major: 19, minor: 0, patch: 0 });
expect(parseVersion('0.0.1')).toEqual({ major: 0, minor: 0, patch: 1 });
});

it('parses versions with pre-release suffixes', () => {
expect(parseVersion('19.0.0-rc.1')).toEqual({ major: 19, minor: 0, patch: 0 });
expect(parseVersion('18.3.0-alpha.1')).toEqual({ major: 18, minor: 3, patch: 0 });
expect(parseVersion('19.0.0-beta.2+build.123')).toEqual({ major: 19, minor: 0, patch: 0 });
});

it('returns null for invalid versions', () => {
expect(parseVersion('')).toBeNull();
expect(parseVersion('invalid')).toBeNull();
expect(parseVersion('18')).toBeNull();
expect(parseVersion('18.3')).toBeNull();
expect(parseVersion('v18.3.1')).toBeNull();
expect(parseVersion('18.3.x')).toBeNull();
});
});

describe('checkVersionAgainstBounds', () => {
describe('caret ranges (maxMinor === -1)', () => {
// ^18.0.0 means >= 18.0.0 and < 19.0.0
const caretBounds: VersionBounds[] = [[18, 0, -1, 0]];

it('matches versions at the minimum', () => {
expect(checkVersionAgainstBounds({ major: 18, minor: 0, patch: 0 }, caretBounds)).toBe(true);
});

it('matches versions with higher minor', () => {
expect(checkVersionAgainstBounds({ major: 18, minor: 1, patch: 0 }, caretBounds)).toBe(true);
expect(checkVersionAgainstBounds({ major: 18, minor: 99, patch: 99 }, caretBounds)).toBe(true);
});

it('matches versions with higher patch on same minor', () => {
expect(checkVersionAgainstBounds({ major: 18, minor: 0, patch: 1 }, caretBounds)).toBe(true);
expect(checkVersionAgainstBounds({ major: 18, minor: 0, patch: 99 }, caretBounds)).toBe(true);
});

it('does not match versions with lower major', () => {
expect(checkVersionAgainstBounds({ major: 17, minor: 99, patch: 99 }, caretBounds)).toBe(false);
});

it('does not match versions with higher major', () => {
expect(checkVersionAgainstBounds({ major: 19, minor: 0, patch: 0 }, caretBounds)).toBe(false);
});

it('does not match versions below the minimum patch', () => {
// ^18.2.5 means >= 18.2.5
const boundsWithPatch: VersionBounds[] = [[18, 2, -1, 5]];
expect(checkVersionAgainstBounds({ major: 18, minor: 2, patch: 4 }, boundsWithPatch)).toBe(false);
expect(checkVersionAgainstBounds({ major: 18, minor: 2, patch: 5 }, boundsWithPatch)).toBe(true);
expect(checkVersionAgainstBounds({ major: 18, minor: 2, patch: 6 }, boundsWithPatch)).toBe(true);
// Higher minor still works
expect(checkVersionAgainstBounds({ major: 18, minor: 3, patch: 0 }, boundsWithPatch)).toBe(true);
});
});

describe('tilde ranges (maxMinor === minMinor)', () => {
// ~19.0.0 means >= 19.0.0 and < 19.1.0
const tildeBounds: VersionBounds[] = [[19, 0, 0, 0]];

it('matches versions at the minimum', () => {
expect(checkVersionAgainstBounds({ major: 19, minor: 0, patch: 0 }, tildeBounds)).toBe(true);
});

it('matches versions with higher patch on same minor', () => {
expect(checkVersionAgainstBounds({ major: 19, minor: 0, patch: 1 }, tildeBounds)).toBe(true);
expect(checkVersionAgainstBounds({ major: 19, minor: 0, patch: 99 }, tildeBounds)).toBe(true);
});

it('does not match versions with different minor', () => {
expect(checkVersionAgainstBounds({ major: 19, minor: 1, patch: 0 }, tildeBounds)).toBe(false);
expect(checkVersionAgainstBounds({ major: 19, minor: 2, patch: 0 }, tildeBounds)).toBe(false);
});

it('does not match versions with different major', () => {
expect(checkVersionAgainstBounds({ major: 18, minor: 0, patch: 0 }, tildeBounds)).toBe(false);
expect(checkVersionAgainstBounds({ major: 20, minor: 0, patch: 0 }, tildeBounds)).toBe(false);
});

it('does not match versions below the minimum patch', () => {
// ~19.0.3 means >= 19.0.3 and < 19.1.0
const boundsWithPatch: VersionBounds[] = [[19, 0, 0, 3]];
expect(checkVersionAgainstBounds({ major: 19, minor: 0, patch: 2 }, boundsWithPatch)).toBe(false);
expect(checkVersionAgainstBounds({ major: 19, minor: 0, patch: 3 }, boundsWithPatch)).toBe(true);
expect(checkVersionAgainstBounds({ major: 19, minor: 0, patch: 4 }, boundsWithPatch)).toBe(true);
});
});

describe('multiple bounds', () => {
// ^18.0.0 || ^19.0.0
const multipleBounds: VersionBounds[] = [
[18, 0, -1, 0],
[19, 0, -1, 0],
];

it('matches versions satisfying any bound', () => {
expect(checkVersionAgainstBounds({ major: 18, minor: 3, patch: 1 }, multipleBounds)).toBe(true);
expect(checkVersionAgainstBounds({ major: 19, minor: 0, patch: 0 }, multipleBounds)).toBe(true);
});

it('does not match versions outside all bounds', () => {
expect(checkVersionAgainstBounds({ major: 17, minor: 0, patch: 0 }, multipleBounds)).toBe(false);
expect(checkVersionAgainstBounds({ major: 20, minor: 0, patch: 0 }, multipleBounds)).toBe(false);
});
});

describe('empty bounds', () => {
it('returns false for empty bounds array', () => {
expect(checkVersionAgainstBounds({ major: 18, minor: 0, patch: 0 }, [])).toBe(false);
});
});
});

describe('isVersionCompatible', () => {
const bounds: VersionBounds[] = [
[18, 0, -1, 0], // ^18.0.0
[19, 0, -1, 0], // ^19.0.0
];

it('returns true for compatible versions', () => {
expect(isVersionCompatible('18.3.1', bounds)).toBe(true);
expect(isVersionCompatible('19.0.0', bounds)).toBe(true);
expect(isVersionCompatible('19.0.0-rc.1', bounds)).toBe(true);
});

it('returns false for incompatible versions', () => {
expect(isVersionCompatible('17.0.0', bounds)).toBe(false);
expect(isVersionCompatible('20.0.0', bounds)).toBe(false);
});

it('returns false for invalid version strings', () => {
expect(isVersionCompatible('', bounds)).toBe(false);
expect(isVersionCompatible('invalid', bounds)).toBe(false);
});
});
97 changes: 97 additions & 0 deletions packages/react/src/utils/versionCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import React from 'react';

// Version bounds format: [major, minMinor, maxMinor, minPatch]
// - maxMinor === -1 means "any minor" (caret range, e.g., ^18.0.0)
// - maxMinor === minMinor means "same minor only" (tilde range, e.g., ~19.0.3)
export type VersionBounds = [major: number, minMinor: number, maxMinor: number, minPatch: number];

declare const __CLERK_UI_SUPPORTED_REACT_BOUNDS__: VersionBounds[];

/**
* Parses a version string into major, minor, and patch numbers.
* Returns null if the version string cannot be parsed.
*
* @example
* parseVersion("18.3.1") // { major: 18, minor: 3, patch: 1 }
* parseVersion("19.0.0-rc.1") // { major: 19, minor: 0, patch: 0 }
* parseVersion("invalid") // null
*/
export function parseVersion(version: string): { major: number; minor: number; patch: number } | null {
const match = version.match(/^(\d+)\.(\d+)\.(\d+)/);
if (!match) {
return null;
}

const [, majorStr, minorStr, patchStr] = match;
return {
major: parseInt(majorStr, 10),
minor: parseInt(minorStr, 10),
patch: parseInt(patchStr, 10),
};
}

/**
* Checks if a parsed version satisfies the given version bounds.
*
* @param version - The parsed version to check
* @param bounds - Array of version bounds to check against
* @returns true if the version satisfies any of the bounds
*/
export function checkVersionAgainstBounds(
version: { major: number; minor: number; patch: number },
bounds: VersionBounds[],
): boolean {
const { major, minor, patch } = version;

return bounds.some(([bMajor, minMinor, maxMinor, minPatch]) => {
if (major !== bMajor) {
return false;
}

if (maxMinor === -1) {
// Caret range: any minor >= minMinor, with patch check for minMinor
return minor > minMinor || (minor === minMinor && patch >= minPatch);
} else {
// Tilde range: specific minor only
return minor === maxMinor && patch >= minPatch;
}
});
}

/**
* Checks if a version string is compatible with the given bounds.
* This is a convenience function that combines parsing and checking.
*
* @param version - The version string to check (e.g., "18.3.1")
* @param bounds - Array of version bounds to check against
* @returns true if the version is compatible, false otherwise
*/
export function isVersionCompatible(version: string, bounds: VersionBounds[]): boolean {
const parsed = parseVersion(version);
if (!parsed) {
return false;
}
return checkVersionAgainstBounds(parsed, bounds);
}

/**
* 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();
Loading
Loading