diff --git a/packages/react/build-utils/parseVersionRange.ts b/packages/react/build-utils/parseVersionRange.ts index 91ba84d6240..814a2b68379 100644 --- a/packages/react/build-utils/parseVersionRange.ts +++ b/packages/react/build-utils/parseVersionRange.ts @@ -1,6 +1,5 @@ -import { coerce } from 'semver'; - import type { VersionBounds } from '@clerk/shared/versionCheck'; +import { coerce } from 'semver'; export type { VersionBounds } from '@clerk/shared/versionCheck'; diff --git a/packages/shared/src/__tests__/versionCheck.spec.ts b/packages/shared/src/__tests__/versionCheck.spec.ts index 9a36f1640c1..b531b06fb4b 100644 --- a/packages/shared/src/__tests__/versionCheck.spec.ts +++ b/packages/shared/src/__tests__/versionCheck.spec.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { checkVersionAgainstBounds, isVersionCompatible, parseVersion, type VersionBounds } from '../versionCheck'; +import { + checkVersionAgainstBounds, + isVersionAtLeast, + isVersionCompatible, + parseVersion, + type VersionBounds, +} from '../versionCheck'; describe('parseVersion', () => { it('parses standard semver versions', () => { @@ -142,3 +148,70 @@ describe('isVersionCompatible', () => { expect(isVersionCompatible('invalid', bounds)).toBe(false); }); }); + +describe('isVersionAtLeast', () => { + describe('returns true when version meets or exceeds minimum', () => { + it('exact match', () => { + expect(isVersionAtLeast('5.100.0', '5.100.0')).toBe(true); + }); + + it('higher patch', () => { + expect(isVersionAtLeast('5.100.1', '5.100.0')).toBe(true); + }); + + it('higher minor', () => { + expect(isVersionAtLeast('5.101.0', '5.100.0')).toBe(true); + expect(isVersionAtLeast('5.114.0', '5.100.0')).toBe(true); + }); + + it('higher major', () => { + expect(isVersionAtLeast('6.0.0', '5.100.0')).toBe(true); + }); + }); + + describe('returns false when version is below minimum', () => { + it('lower patch', () => { + expect(isVersionAtLeast('5.100.0', '5.100.1')).toBe(false); + }); + + it('lower minor', () => { + expect(isVersionAtLeast('5.99.0', '5.100.0')).toBe(false); + expect(isVersionAtLeast('5.99.999', '5.100.0')).toBe(false); + }); + + it('lower major', () => { + expect(isVersionAtLeast('4.999.999', '5.100.0')).toBe(false); + }); + }); + + describe('handles pre-release versions', () => { + it('treats pre-release as base version', () => { + expect(isVersionAtLeast('5.100.0-canary.123', '5.100.0')).toBe(true); + expect(isVersionAtLeast('5.114.0-snapshot.456', '5.100.0')).toBe(true); + }); + + it('compares base versions ignoring pre-release suffix', () => { + expect(isVersionAtLeast('5.99.0-canary.999', '5.100.0')).toBe(false); + }); + }); + + describe('handles edge cases', () => { + it('returns false for null/undefined version', () => { + expect(isVersionAtLeast(null, '5.100.0')).toBe(false); + expect(isVersionAtLeast(undefined, '5.100.0')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(isVersionAtLeast('', '5.100.0')).toBe(false); + }); + + it('returns false for invalid version string', () => { + expect(isVersionAtLeast('invalid', '5.100.0')).toBe(false); + expect(isVersionAtLeast('5.100', '5.100.0')).toBe(false); + }); + + it('returns false if minVersion cannot be parsed', () => { + expect(isVersionAtLeast('5.100.0', 'invalid')).toBe(false); + }); + }); +}); diff --git a/packages/shared/src/versionCheck.ts b/packages/shared/src/versionCheck.ts index 1918a612df3..1b061aa5522 100644 --- a/packages/shared/src/versionCheck.ts +++ b/packages/shared/src/versionCheck.ts @@ -30,7 +30,10 @@ export function parseVersion(version: string): { major: number; minor: number; p * Checks if a parsed version satisfies the given version bounds. * * @param version - The parsed version to check + * @param version.major * @param bounds - Array of version bounds to check against + * @param version.minor + * @param version.patch * @returns true if the version satisfies any of the bounds */ export function checkVersionAgainstBounds( @@ -69,3 +72,38 @@ export function isVersionCompatible(version: string, bounds: VersionBounds[]): b } return checkVersionAgainstBounds(parsed, bounds); } + +/** + * Returns true if the given version is at least the minimum version. + * Both versions are compared by their major.minor.patch components only. + * Pre-release suffixes are ignored (e.g., "5.114.0-canary.123" is treated as "5.114.0"). + * + * @param version - The version string to check (e.g., "5.114.0") + * @param minVersion - The minimum required version (e.g., "5.100.0") + * @returns true if version >= minVersion, false otherwise (including if either cannot be parsed) + * + * @example + * isVersionAtLeast("5.114.0", "5.100.0") // true + * isVersionAtLeast("5.99.0", "5.100.0") // false + * isVersionAtLeast("5.100.0-canary.123", "5.100.0") // true + */ +export function isVersionAtLeast(version: string | undefined | null, minVersion: string): boolean { + if (!version) { + return false; + } + + const parsed = parseVersion(version); + const minParsed = parseVersion(minVersion); + + if (!parsed || !minParsed) { + return false; + } + + if (parsed.major !== minParsed.major) { + return parsed.major > minParsed.major; + } + if (parsed.minor !== minParsed.minor) { + return parsed.minor > minParsed.minor; + } + return parsed.patch >= minParsed.patch; +} diff --git a/packages/ui/src/ClerkUi.ts b/packages/ui/src/ClerkUi.ts index 411e44579af..dcd8a00c5b2 100644 --- a/packages/ui/src/ClerkUi.ts +++ b/packages/ui/src/ClerkUi.ts @@ -1,8 +1,12 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { logger } from '@clerk/shared/logger'; import type { ModuleManager } from '@clerk/shared/moduleManager'; import type { Clerk, ClerkOptions, EnvironmentResource } from '@clerk/shared/types'; import type { ClerkUiInstance, ComponentControls as SharedComponentControls } from '@clerk/shared/ui'; +import { isVersionAtLeast, parseVersion } from '@clerk/shared/versionCheck'; import { type MountComponentRenderer, mountComponentRenderer } from './Components'; +import { MIN_CLERK_JS_VERSION } from './constants'; export class ClerkUi implements ClerkUiInstance { static version = __PKG_VERSION__; @@ -16,6 +20,33 @@ export class ClerkUi implements ClerkUiInstance { options: ClerkOptions, moduleManager: ModuleManager, ) { + const clerk = getClerk(); + const clerkVersion = clerk?.version; + const isDevelopmentInstance = clerk?.instanceType === 'development'; + const parsedVersion = parseVersion(clerkVersion ?? ''); + + let incompatibilityMessage: string | null = null; + + if (parsedVersion && !isVersionAtLeast(clerkVersion, MIN_CLERK_JS_VERSION)) { + incompatibilityMessage = + `@clerk/ui@${ClerkUi.version} requires @clerk/clerk-js@>=${MIN_CLERK_JS_VERSION}, ` + + `but found @clerk/clerk-js@${clerkVersion}. ` + + `Please upgrade @clerk/clerk-js (or your framework SDK) to a compatible version.`; + } else if (!parsedVersion && !moduleManager) { + incompatibilityMessage = + `@clerk/ui@${ClerkUi.version} requires @clerk/clerk-js@>=${MIN_CLERK_JS_VERSION}, ` + + `but found an incompatible version${clerkVersion ? ` (${clerkVersion})` : ''}. ` + + `Please upgrade @clerk/clerk-js (or your framework SDK) to a compatible version.`; + } + + if (incompatibilityMessage) { + if (isDevelopmentInstance) { + logger.warnOnce(incompatibilityMessage); + } else { + throw new ClerkRuntimeError(incompatibilityMessage, { code: 'clerk_ui_version_mismatch' }); + } + } + this.#componentRenderer = mountComponentRenderer(getClerk, getEnvironment, options, moduleManager); } diff --git a/packages/ui/src/__tests__/ClerkUi.test.ts b/packages/ui/src/__tests__/ClerkUi.test.ts new file mode 100644 index 00000000000..050eaef563d --- /dev/null +++ b/packages/ui/src/__tests__/ClerkUi.test.ts @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('../Components', () => ({ + mountComponentRenderer: vi.fn(() => ({ + ensureMounted: vi.fn().mockResolvedValue({}), + })), +})); + +vi.mock('@clerk/shared/logger', () => ({ + logger: { + warnOnce: vi.fn(), + }, +})); + +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { logger } from '@clerk/shared/logger'; +import type { Clerk, ClerkOptions } from '@clerk/shared/types'; + +import { ClerkUi } from '../ClerkUi'; +import { MIN_CLERK_JS_VERSION } from '../constants'; + +describe('ClerkUi version check', () => { + const mockModuleManager = { load: vi.fn(), unload: vi.fn(), isLoaded: vi.fn() }; + const mockOptions: ClerkOptions = {}; + const getEnvironment = () => null; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('compatible versions', () => { + test('accepts exact minimum version (6.0.0)', () => { + const getClerk = () => ({ version: '6.0.0', instanceType: 'production' }) as Clerk; + + expect(() => { + new ClerkUi(getClerk, getEnvironment, mockOptions, mockModuleManager as any); + }).not.toThrow(); + }); + + test('accepts versions above minimum (6.5.0)', () => { + const getClerk = () => ({ version: '6.5.0', instanceType: 'production' }) as Clerk; + + expect(() => { + new ClerkUi(getClerk, getEnvironment, mockOptions, mockModuleManager as any); + }).not.toThrow(); + }); + + test('accepts pre-release at minimum version (6.0.0-canary)', () => { + const getClerk = () => ({ version: '6.0.0-canary.123', instanceType: 'production' }) as Clerk; + + expect(() => { + new ClerkUi(getClerk, getEnvironment, mockOptions, mockModuleManager as any); + }).not.toThrow(); + }); + + test('accepts pre-release above minimum (6.1.0-snapshot)', () => { + const getClerk = () => ({ version: '6.1.0-snapshot.456', instanceType: 'production' }) as Clerk; + + expect(() => { + new ClerkUi(getClerk, getEnvironment, mockOptions, mockModuleManager as any); + }).not.toThrow(); + }); + }); + + describe('outdated versions in production', () => { + test('rejects outdated versions with ClerkRuntimeError', () => { + const getClerk = () => ({ version: '5.0.0', instanceType: 'production' }) as Clerk; + + expect(() => { + new ClerkUi(getClerk, getEnvironment, mockOptions, mockModuleManager as any); + }).toThrow(ClerkRuntimeError); + }); + + test('includes clerk_ui_version_mismatch error code', () => { + const getClerk = () => ({ version: '5.0.0', instanceType: 'production' }) as Clerk; + + try { + new ClerkUi(getClerk, getEnvironment, mockOptions, mockModuleManager as any); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ClerkRuntimeError); + expect((error as ClerkRuntimeError).code).toBe('clerk_ui_version_mismatch'); + } + }); + + test('error mentions @clerk/ui version', () => { + const getClerk = () => ({ version: '5.0.0', instanceType: 'production' }) as Clerk; + + try { + new ClerkUi(getClerk, getEnvironment, mockOptions, mockModuleManager as any); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).toContain('@clerk/ui@'); + } + }); + + test('error mentions detected clerk-js version', () => { + const getClerk = () => ({ version: '5.0.0', instanceType: 'production' }) as Clerk; + + try { + new ClerkUi(getClerk, getEnvironment, mockOptions, mockModuleManager as any); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).toContain('@clerk/clerk-js@5.0.0'); + } + }); + + test('error mentions minimum required version', () => { + const getClerk = () => ({ version: '5.0.0', instanceType: 'production' }) as Clerk; + + try { + new ClerkUi(getClerk, getEnvironment, mockOptions, mockModuleManager as any); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).toContain(`>=${MIN_CLERK_JS_VERSION}`); + } + }); + + test('error includes upgrade instructions', () => { + const getClerk = () => ({ version: '5.0.0', instanceType: 'production' }) as Clerk; + + try { + new ClerkUi(getClerk, getEnvironment, mockOptions, mockModuleManager as any); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).toContain('Please upgrade'); + } + }); + + test('rejects older major versions (4.x)', () => { + const getClerk = () => ({ version: '4.999.999', instanceType: 'production' }) as Clerk; + + expect(() => { + new ClerkUi(getClerk, getEnvironment, mockOptions, mockModuleManager as any); + }).toThrow(ClerkRuntimeError); + }); + }); + + describe('outdated versions in development', () => { + test('warns instead of throwing for outdated versions', () => { + const getClerk = () => ({ version: '5.0.0', instanceType: 'development' }) as Clerk; + + expect(() => { + new ClerkUi(getClerk, getEnvironment, mockOptions, mockModuleManager as any); + }).not.toThrow(); + + expect(logger.warnOnce).toHaveBeenCalledTimes(1); + expect(logger.warnOnce).toHaveBeenCalledWith(expect.stringContaining('@clerk/ui@')); + }); + + test('warning includes version details', () => { + const getClerk = () => ({ version: '5.0.0', instanceType: 'development' }) as Clerk; + + new ClerkUi(getClerk, getEnvironment, mockOptions, mockModuleManager as any); + + expect(logger.warnOnce).toHaveBeenCalledWith(expect.stringContaining('@clerk/clerk-js@5.0.0')); + expect(logger.warnOnce).toHaveBeenCalledWith(expect.stringContaining(`>=${MIN_CLERK_JS_VERSION}`)); + }); + }); + + describe('unknown version handling', () => { + test('trusts moduleManager for local dev builds (undefined version)', () => { + const getClerk = () => ({ version: undefined, instanceType: 'production' }) as Clerk; + + expect(() => { + new ClerkUi(getClerk, getEnvironment, mockOptions, mockModuleManager as any); + }).not.toThrow(); + }); + + test('trusts moduleManager for builds with empty version string', () => { + const getClerk = () => ({ version: '', instanceType: 'production' }) as Clerk; + + expect(() => { + new ClerkUi(getClerk, getEnvironment, mockOptions, mockModuleManager as any); + }).not.toThrow(); + }); + + test('trusts moduleManager for builds with unparseable version format', () => { + const getClerk = () => ({ version: 'invalid', instanceType: 'production' }) as Clerk; + + expect(() => { + new ClerkUi(getClerk, getEnvironment, mockOptions, mockModuleManager as any); + }).not.toThrow(); + }); + + test('rejects unknown version without moduleManager (production)', () => { + const getClerk = () => ({ version: undefined, instanceType: 'production' }) as Clerk; + + expect(() => { + new ClerkUi(getClerk, getEnvironment, mockOptions, null as any); + }).toThrow(ClerkRuntimeError); + }); + + test('warns for unknown version without moduleManager (development)', () => { + const getClerk = () => ({ version: undefined, instanceType: 'development' }) as Clerk; + + expect(() => { + new ClerkUi(getClerk, getEnvironment, mockOptions, null as any); + }).not.toThrow(); + + expect(logger.warnOnce).toHaveBeenCalledTimes(1); + }); + + test('error for unknown version includes helpful message', () => { + const getClerk = () => ({ version: undefined, instanceType: 'production' }) as Clerk; + + try { + new ClerkUi(getClerk, getEnvironment, mockOptions, null as any); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as ClerkRuntimeError).code).toBe('clerk_ui_version_mismatch'); + expect((error as Error).message).toContain('incompatible version'); + } + }); + }); +}); diff --git a/packages/ui/src/constants.ts b/packages/ui/src/constants.ts index b32e4bc8b7f..d9aa02d494b 100644 --- a/packages/ui/src/constants.ts +++ b/packages/ui/src/constants.ts @@ -1,3 +1,5 @@ +export const MIN_CLERK_JS_VERSION = '5.112.0'; + export const USER_PROFILE_NAVBAR_ROUTE_ID = { ACCOUNT: 'account', SECURITY: 'security',