diff --git a/.changeset/shared-react-variant.md b/.changeset/shared-react-variant.md new file mode 100644 index 00000000000..a3104241a0b --- /dev/null +++ b/.changeset/shared-react-variant.md @@ -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. diff --git a/packages/nextjs/src/utils/clerk-script.tsx b/packages/nextjs/src/utils/clerk-script.tsx index aceb76c4d9d..8d65d10ab81 100644 --- a/packages/nextjs/src/utils/clerk-script.tsx +++ b/packages/nextjs/src/utils/clerk-script.tsx @@ -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; @@ -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) { @@ -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 ( diff --git a/packages/react/.gitignore b/packages/react/.gitignore index 7107e6a4038..99cd8e73638 100644 --- a/packages/react/.gitignore +++ b/packages/react/.gitignore @@ -1,3 +1,4 @@ /*/ !/src/ !/docs/ +!/build-utils/ diff --git a/packages/react/build-utils/__tests__/parseVersionRange.test.ts b/packages/react/build-utils/__tests__/parseVersionRange.test.ts new file mode 100644 index 00000000000..0fd37544d49 --- /dev/null +++ b/packages/react/build-utils/__tests__/parseVersionRange.test.ts @@ -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([[18, 0, -1, 0]]); + }); + + it('parses caret range with non-zero minor', () => { + expect(parseRangeToBounds('^18.2.0')).toEqual([[18, 2, -1, 0]]); + }); + + it('parses caret range with non-zero patch', () => { + expect(parseRangeToBounds('^18.2.5')).toEqual([[18, 2, -1, 5]]); + }); + }); + + describe('tilde ranges', () => { + it('parses simple tilde range', () => { + expect(parseRangeToBounds('~19.0.0')).toEqual([[19, 0, 0, 0]]); + }); + + it('parses tilde range with non-zero minor', () => { + expect(parseRangeToBounds('~19.1.0')).toEqual([[19, 1, 1, 0]]); + }); + + it('parses tilde range with non-zero patch', () => { + expect(parseRangeToBounds('~19.0.3')).toEqual([[19, 0, 0, 3]]); + }); + }); + + describe('exact versions', () => { + it('treats exact version as caret range', () => { + expect(parseRangeToBounds('18.3.1')).toEqual([[18, 3, -1, 1]]); + }); + }); + + describe('OR combinations', () => { + it('parses two caret ranges', () => { + expect(parseRangeToBounds('^18.0.0 || ^19.0.0')).toEqual([ + [18, 0, -1, 0], + [19, 0, -1, 0], + ]); + }); + + it('parses mixed caret and tilde ranges', () => { + expect(parseRangeToBounds('^18.0.0 || ~19.0.3')).toEqual([ + [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([ + [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([ + [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([ + [18, 0, -1, 0], + [19, 0, -1, 0], + ]); + }); + + it('returns empty array for invalid input', () => { + expect(parseRangeToBounds('invalid')).toEqual([]); + expect(parseRangeToBounds('')).toEqual([]); + }); + + it('skips invalid parts in OR combinations', () => { + expect(parseRangeToBounds('^18.0.0 || invalid || ^19.0.0')).toEqual([ + [18, 0, -1, 0], + [19, 0, -1, 0], + ]); + }); + + it('handles prerelease versions', () => { + // semver.coerce strips prerelease info + expect(parseRangeToBounds('~19.3.0-0')).toEqual([[19, 3, 3, 0]]); + expect(parseRangeToBounds('^19.0.0-rc.1')).toEqual([[19, 0, -1, 0]]); + }); + }); +}); diff --git a/packages/react/build-utils/parseVersionRange.ts b/packages/react/build-utils/parseVersionRange.ts new file mode 100644 index 00000000000..447b85e11d0 --- /dev/null +++ b/packages/react/build-utils/parseVersionRange.ts @@ -0,0 +1,47 @@ +import { coerce } from 'semver'; + +// 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]; + +/** + * 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; +} diff --git a/packages/react/package.json b/packages/react/package.json index 3968872e86b..65ff93c930e 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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", diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index 6d507da4b6c..7013213c17e 100644 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ b/packages/react/src/contexts/ClerkContextProvider.tsx @@ -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'; @@ -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(() => { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 3ffd47e9e7d..a45b0cfab8d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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'; diff --git a/packages/react/src/internal.ts b/packages/react/src/internal.ts index 26e71d2e998..5496de80277 100644 --- a/packages/react/src/internal.ts +++ b/packages/react/src/internal.ts @@ -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, diff --git a/packages/react/src/utils/__tests__/versionCheck.test.ts b/packages/react/src/utils/__tests__/versionCheck.test.ts new file mode 100644 index 00000000000..9a36f1640c1 --- /dev/null +++ b/packages/react/src/utils/__tests__/versionCheck.test.ts @@ -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); + }); +}); diff --git a/packages/react/src/utils/versionCheck.ts b/packages/react/src/utils/versionCheck.ts new file mode 100644 index 00000000000..2e192a3c7c2 --- /dev/null +++ b/packages/react/src/utils/versionCheck.ts @@ -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(); diff --git a/packages/react/tsup.config.ts b/packages/react/tsup.config.ts index b529ea7ff08..eb3ac06e9a7 100644 --- a/packages/react/tsup.config.ts +++ b/packages/react/tsup.config.ts @@ -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 } 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[] { + 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: { @@ -28,6 +78,7 @@ export default defineConfig(overrideOptions => { PACKAGE_VERSION: `"${version}"`, JS_PACKAGE_VERSION: `"${clerkJsVersion}"`, __DEV__: `${isWatch}`, + __CLERK_UI_SUPPORTED_REACT_BOUNDS__: JSON.stringify(clerkUiSupportedReactBounds), }, }; }); diff --git a/packages/shared/src/loadClerkJsScript.ts b/packages/shared/src/loadClerkJsScript.ts index 2e3a6012dbe..6f48971f1d6 100644 --- a/packages/shared/src/loadClerkJsScript.ts +++ b/packages/shared/src/loadClerkJsScript.ts @@ -30,6 +30,7 @@ export type LoadClerkJsScriptOptions = { export type LoadClerkUiScriptOptions = { publishableKey: string; clerkUiUrl?: string; + clerkUiVariant?: 'shared' | ''; clerkUiVersion?: string; proxyUrl?: string; domain?: string; @@ -229,15 +230,16 @@ export const clerkJsScriptUrl = (opts: LoadClerkJsScriptOptions) => { }; export const clerkUiScriptUrl = (opts: LoadClerkUiScriptOptions) => { - const { clerkUiUrl, clerkUiVersion, proxyUrl, domain, publishableKey } = opts; + const { clerkUiUrl, clerkUiVariant, clerkUiVersion, proxyUrl, domain, publishableKey } = opts; if (clerkUiUrl) { return clerkUiUrl; } const scriptHost = buildScriptHost({ publishableKey, proxyUrl, domain }); + const variant = clerkUiVariant ? `${clerkUiVariant}.` : ''; const version = versionSelector(clerkUiVersion, UI_PACKAGE_VERSION); - return `https://${scriptHost}/npm/@clerk/ui@${version}/dist/ui.browser.js`; + return `https://${scriptHost}/npm/@clerk/ui@${version}/dist/ui.${variant}browser.js`; }; export const buildClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => { diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 9d3f7945f38..1ab33e1375d 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -2409,6 +2409,11 @@ export type IsomorphicClerkOptions = Without & { * The URL that `@clerk/ui` should be hot-loaded from. */ clerkUiUrl?: string; + /** + * If set to `'shared'`, loads a variant of `@clerk/ui` that expects React to be provided by the host application via `globalThis.__clerkSharedModules`. + * This reduces bundle size when using framework packages like `@clerk/react`. + */ + clerkUiVariant?: 'shared' | ''; /** * The Clerk Publishable Key for your instance. This can be found on the [API keys](https://dashboard.clerk.com/last-active?path=api-keys) page in the Clerk Dashboard. */ diff --git a/packages/ui/package.json b/packages/ui/package.json index bf3ee16b3dd..ca51a860ae7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -10,7 +10,8 @@ "license": "MIT", "author": "Clerk", "sideEffects": [ - "./src/utils/setWebpackChunkPublicPath.ts" + "./src/utils/setWebpackChunkPublicPath.ts", + "./register/*" ], "type": "module", "exports": { @@ -40,11 +41,22 @@ "default": "./dist/themes/experimental.js" }, "./themes/shadcn.css": "./dist/themes/shadcn.css", + "./register": { + "import": { + "types": "./register/index.d.ts", + "default": "./register/index.mjs" + }, + "require": { + "types": "./register/index.d.cts", + "default": "./register/index.cjs" + } + }, "./package.json": "./package.json" }, "types": "dist/index.d.ts", "files": [ - "dist" + "dist", + "register" ], "scripts": { "build": "pnpm build:umd && pnpm build:esm", diff --git a/packages/ui/register/index.cjs b/packages/ui/register/index.cjs new file mode 100644 index 00000000000..177fb003315 --- /dev/null +++ b/packages/ui/register/index.cjs @@ -0,0 +1,45 @@ +/** + * Register React dependencies for sharing with @clerk/ui's shared variant. + * + * Import this module BEFORE loading the ui.shared.browser.js bundle: + * + * ```js + * require('@clerk/ui/register'); + * // Now load clerk-js which will load ui.shared.browser.js + * ``` + * + * This enables @clerk/ui to use the host app's React instead of bundling its own, + * reducing the overall bundle size. + */ +'use strict'; + +const react = require('react'); +const reactDom = require('react-dom'); +const reactDomClient = require('react-dom/client'); +const jsxRuntime = require('react/jsx-runtime'); + +// Only register if not already registered to avoid overwriting with potentially +// different React versions in complex module resolution scenarios. +if (globalThis.__clerkSharedModules) { + // Warn if the already-registered React version differs from this import. + // This could indicate multiple React versions in the bundle, which may cause issues. + const existingVersion = globalThis.__clerkSharedModules.react?.version; + if (existingVersion && existingVersion !== react.version) { + console.warn( + '[@clerk/ui/register] React version mismatch detected. ' + + 'Already registered: ' + + existingVersion + + ', current import: ' + + react.version + + '. ' + + 'This may cause issues with the shared @clerk/ui variant.', + ); + } +} else { + globalThis.__clerkSharedModules = { + react, + 'react-dom': reactDom, + 'react-dom/client': reactDomClient, + 'react/jsx-runtime': jsxRuntime, + }; +} diff --git a/packages/ui/register/index.d.cts b/packages/ui/register/index.d.cts new file mode 100644 index 00000000000..23ccddf5e24 --- /dev/null +++ b/packages/ui/register/index.d.cts @@ -0,0 +1,17 @@ +/** + * Registers React and ReactDOM on the global shared modules registry. + * Import this before loading @clerk/ui's shared variant to enable dependency sharing. + */ + +declare global { + var __clerkSharedModules: + | { + react: typeof import('react'); + 'react-dom': typeof import('react-dom'); + 'react-dom/client': typeof import('react-dom/client'); + 'react/jsx-runtime': typeof import('react/jsx-runtime'); + } + | undefined; +} + +export {}; diff --git a/packages/ui/register/index.d.ts b/packages/ui/register/index.d.ts new file mode 100644 index 00000000000..63ecbe7091f --- /dev/null +++ b/packages/ui/register/index.d.ts @@ -0,0 +1,16 @@ +/** + * Registers React and ReactDOM on the global shared modules registry. + * Import this before loading @clerk/ui's shared variant to enable dependency sharing. + */ +export {}; + +declare global { + var __clerkSharedModules: + | { + react: typeof import('react'); + 'react-dom': typeof import('react-dom'); + 'react-dom/client': typeof import('react-dom/client'); + 'react/jsx-runtime': typeof import('react/jsx-runtime'); + } + | undefined; +} diff --git a/packages/ui/register/index.mjs b/packages/ui/register/index.mjs new file mode 100644 index 00000000000..fe14f70baec --- /dev/null +++ b/packages/ui/register/index.mjs @@ -0,0 +1,40 @@ +/** + * Register React dependencies for sharing with @clerk/ui's shared variant. + * + * Import this module BEFORE loading the ui.shared.browser.js bundle: + * + * ```js + * import '@clerk/ui/register'; + * // Now load clerk-js which will load ui.shared.browser.js + * ``` + * + * This enables @clerk/ui to use the host app's React instead of bundling its own, + * reducing the overall bundle size. + */ + +import * as react from 'react'; +import * as reactDom from 'react-dom'; +import * as reactDomClient from 'react-dom/client'; +import * as jsxRuntime from 'react/jsx-runtime'; + +// Only register if not already registered to avoid overwriting with potentially +// different React versions in complex module resolution scenarios. +if (globalThis.__clerkSharedModules) { + // Warn if the already-registered React version differs from this import. + // This could indicate multiple React versions in the bundle, which may cause issues. + const existingVersion = globalThis.__clerkSharedModules.react?.version; + if (existingVersion && existingVersion !== react.version) { + console.warn( + `[@clerk/ui/register] React version mismatch detected. ` + + `Already registered: ${existingVersion}, current import: ${react.version}. ` + + `This may cause issues with the shared @clerk/ui variant.`, + ); + } +} else { + globalThis.__clerkSharedModules = { + react, + 'react-dom': reactDom, + 'react-dom/client': reactDomClient, + 'react/jsx-runtime': jsxRuntime, + }; +} diff --git a/packages/ui/rspack.config.js b/packages/ui/rspack.config.js index fbe4e7237bc..27c11311477 100644 --- a/packages/ui/rspack.config.js +++ b/packages/ui/rspack.config.js @@ -17,14 +17,37 @@ const __dirname = path.dirname(__filename); const isProduction = mode => mode === 'production'; const isDevelopment = mode => !isProduction(mode); +/** + * Externals handler for the shared variant that reads React from globalThis.__clerkSharedModules. + * This allows the host application's React to be shared with @clerk/ui. + * @type {import('@rspack/core').ExternalItemFunctionData} + */ +const sharedReactExternalsHandler = ({ request }, callback) => { + if (request === 'react') { + return callback(null, ['__clerkSharedModules', 'react'], 'root'); + } + if (request === 'react-dom') { + return callback(null, ['__clerkSharedModules', 'react-dom'], 'root'); + } + if (request === 'react-dom/client') { + return callback(null, ['__clerkSharedModules', 'react-dom/client'], 'root'); + } + if (request === 'react/jsx-runtime') { + return callback(null, ['__clerkSharedModules', 'react/jsx-runtime'], 'root'); + } + callback(); +}; + const variants = { uiBrowser: 'ui.browser', uiLegacyBrowser: 'ui.legacy.browser', + uiSharedBrowser: 'ui.shared.browser', }; const variantToSourceFile = { [variants.uiBrowser]: './src/index.browser.ts', [variants.uiLegacyBrowser]: './src/index.legacy.browser.ts', + [variants.uiSharedBrowser]: './src/index.browser.ts', // Same entry, different externals }; /** @@ -208,23 +231,37 @@ const prodConfig = ({ mode, analysis }) => { commonForProdBrowser({ targets: packageJSON.browserslistLegacy, useCoreJs: true }), ); + // Shared variant - externalizes react/react-dom to use host app's versions + // Expects host to provide these via globalThis.__clerkSharedModules + const uiSharedBrowser = merge( + entryForVariant(variants.uiSharedBrowser), + common({ mode, variant: variants.uiSharedBrowser }), + commonForProdBrowser(), + { + externals: [sharedReactExternalsHandler], + }, + ); + // webpack-bundle-analyzer only supports a single build, use uiBrowser as that's the default build we serve if (analysis) { return [uiBrowser]; } - return [uiBrowser, uiLegacyBrowser]; + return [uiBrowser, uiLegacyBrowser, uiSharedBrowser]; }; /** * Development configuration - only builds browser bundle with dev server * @param {'development'|'production'} mode * @param {object} env + * @param {boolean} [env.shared] - If true, externalize React to globalThis.__clerkSharedModules (for use with @clerk/react). + * If false/unset, bundle React normally (for standalone or non-React framework usage). * @returns {import('@rspack/core').Configuration} */ const devConfig = (mode, env) => { const devUrl = new URL(env.devOrigin || 'https://ui.lclclerk.com'); const port = Number(new URL(env.devOrigin ?? 'http://localhost:4011').port || 4011); + const useSharedReact = Boolean(env.shared); return merge(entryForVariant(variants.uiBrowser), common({ mode, variant: variants.uiBrowser }), { module: { @@ -258,6 +295,9 @@ const devConfig = (mode, env) => { type: 'memory', }, }, + // Only externalize React when using the shared variant (e.g., with @clerk/react). + // For standalone usage or non-React frameworks, bundle React normally. + ...(useSharedReact ? { externals: [sharedReactExternalsHandler] } : {}), }); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10b45161c90..ed4eb9eb1d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -800,6 +800,12 @@ importers: '@types/semver': specifier: ^7.7.1 version: 7.7.1 + semver: + specifier: ^7.7.1 + version: 7.7.3 + yaml: + specifier: ^2.8.0 + version: 2.8.1 packages/react-router: dependencies: