diff --git a/packages/clerk-js/rspack.config.js b/packages/clerk-js/rspack.config.js index 925698ee0f9..4c9239979a6 100644 --- a/packages/clerk-js/rspack.config.js +++ b/packages/clerk-js/rspack.config.js @@ -14,6 +14,7 @@ const variants = { clerk: 'clerk', clerkNoRHC: 'clerk.no-rhc', // Omit Remotely Hosted Code clerkBrowser: 'clerk.browser', + clerkExperimental: 'clerk.experimental', clerkHeadless: 'clerk.headless', clerkHeadlessBrowser: 'clerk.headless.browser', clerkLegacyBrowser: 'clerk.legacy.browser', @@ -24,6 +25,7 @@ const variantToSourceFile = { [variants.clerk]: './src/index.ts', [variants.clerkNoRHC]: './src/index.ts', [variants.clerkBrowser]: './src/index.browser.ts', + [variants.clerkExperimental]: './src/index.ts', [variants.clerkHeadless]: './src/index.headless.ts', [variants.clerkHeadlessBrowser]: './src/index.headless.browser.ts', [variants.clerkLegacyBrowser]: './src/index.legacy.browser.ts', @@ -51,15 +53,16 @@ const common = ({ mode, variant, disableRHC = false }) => { }, plugins: [ new rspack.DefinePlugin({ - __DEV__: isDevelopment(mode), - __PKG_VERSION__: JSON.stringify(packageJSON.version), - __PKG_NAME__: JSON.stringify(packageJSON.name), /** * Build time feature flags. */ - __BUILD_FLAG_KEYLESS_UI__: isDevelopment(mode), __BUILD_DISABLE_RHC__: JSON.stringify(disableRHC), + __BUILD_FLAG_KEYLESS_UI__: isDevelopment(mode), __BUILD_VARIANT_CHIPS__: variant === variants.clerkCHIPS, + __BUILD_VARIANT_EXPERIMENTAL__: variant === variants.clerkExperimental, + __DEV__: isDevelopment(mode), + __PKG_NAME__: JSON.stringify(packageJSON.name), + __PKG_VERSION__: JSON.stringify(packageJSON.version), }), new rspack.EnvironmentPlugin({ CLERK_ENV: mode, diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index d49aefd2186..a0fbb9b246b 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -37,10 +37,17 @@ import { TokenId } from '@/utils/tokenId'; import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../errors'; import { eventBus, events } from '../events'; +import { TokenService } from '../services/TokenService'; import { SessionTokenCache } from '../tokenCache'; import { BaseResource, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; +type TokenFetcher = (params: { + organizationId?: string | null; + sessionId: string; + template?: string; +}) => Promise; + export class Session extends BaseResource implements SessionResource { pathRoot = '/client/sessions'; @@ -58,6 +65,7 @@ export class Session extends BaseResource implements SessionResource { abandonAt!: Date; createdAt!: Date; updatedAt!: Date; + private tokenService: TokenService | null = null; static isSessionResource(resource: unknown): resource is Session { return !!resource && resource instanceof Session; @@ -68,6 +76,26 @@ export class Session extends BaseResource implements SessionResource { this.fromJSON(data); this.#hydrateCache(this.lastActiveToken); + + if (__BUILD_VARIANT_EXPERIMENTAL__) { + this.tokenService = new TokenService(this.id, { + fetcher: this.createTokenFetcher(), + onTokenResolved: token => { + eventBus.emit(events.TokenUpdate, { token }); + if (token.jwt) { + this.lastActiveToken = token; + eventBus.emit(events.SessionTokenResolved, null); + } + }, + }); + + if (this.lastActiveToken) { + const cacheKey = this.tokenService.buildCacheKey(); + const ingestedToken = + this.lastActiveToken instanceof Token ? this.lastActiveToken : new Token(this.lastActiveToken as any); + this.tokenService.ingestToken(ingestedToken, cacheKey); + } + } } end = (): Promise => { @@ -102,6 +130,10 @@ export class Session extends BaseResource implements SessionResource { }; getToken: GetToken = async (options?: GetTokenOptions): Promise => { + if (this.tokenService) { + return this.tokenService.getToken(options); + } + // This will retry the getToken call if it fails with a non-4xx error // We're going to trigger 8 retries in the span of ~3 minutes, // Example delays: 3s, 5s, 13s, 19s, 26s, 34s, 43s, 50s, total: ~193s @@ -139,6 +171,14 @@ export class Session extends BaseResource implements SessionResource { } }; + private createTokenFetcher(): TokenFetcher { + return async params => { + const path = params.template ? `${this.path()}/tokens/${params.template}` : `${this.path()}/tokens`; + const queryParams = params.template ? {} : { organizationId: params.organizationId }; + return Token.create(path, queryParams); + }; + } + // If it's a session token, retrieve it with their session id, otherwise it's a jwt template token // and retrieve it using the session id concatenated with the jwt template name. // e.g. session id is 'sess_abc12345' and jwt template name is 'haris' diff --git a/packages/clerk-js/src/core/services/TokenService.ts b/packages/clerk-js/src/core/services/TokenService.ts new file mode 100644 index 00000000000..6fd8c885502 --- /dev/null +++ b/packages/clerk-js/src/core/services/TokenService.ts @@ -0,0 +1,372 @@ +import { is4xxError } from '@clerk/shared/error'; +import type { GetTokenOptions } from '@clerk/shared/types'; + +import { Token } from '../resources/Token'; + +type TokenCacheKey = string; + +type TokenStateIdle = { + status: 'idle'; +}; + +type TokenStateFetching = { + promise: Promise; + startedAt: number; + status: 'fetching'; +}; + +type TokenStateValid = { + expiresAt: number; + refreshTimeoutId: ReturnType | null; + status: 'valid'; + token: Token; +}; + +type TokenStateRefreshing = { + currentToken: Token; + promise: Promise; + startedAt: number; + status: 'refreshing'; +}; + +type TokenStateError = { + error: Error; + failedAt: number; + nextRetryAt: number | null; + retryCount: number; + status: 'error'; +}; + +type TokenState = TokenStateIdle | TokenStateFetching | TokenStateValid | TokenStateRefreshing | TokenStateError; + +interface TokenServiceOptions { + fetcher: TokenFetcher; + onTokenError?: (error: Error, cacheKey: TokenCacheKey) => void; + onTokenResolved?: (token: Token, cacheKey: TokenCacheKey) => void; + refreshBufferSeconds?: number; + retryConfig?: RetryConfig; +} + +interface TokenFetcher { + (params: TokenFetchParams): Promise; +} + +interface TokenFetchParams { + organizationId?: string | null; + sessionId: string; + template?: string; +} + +interface RetryConfig { + factor: number; + initialDelayMs: number; + maxDelayMs: number; + maxRetries: number; + shouldRetry: (error: Error) => boolean; +} + +const DEFAULT_REFRESH_BUFFER_SECONDS = 10; +const DEFAULT_RETRY_CONFIG: RetryConfig = { + factor: 1.55, + initialDelayMs: 3000, + maxDelayMs: 50000, + maxRetries: 8, + shouldRetry: error => !is4xxError(error), +}; + +export class TokenService { + private cache = new Map(); + + private destroyed = false; + + private options: Required; + + private sessionId: string; + + constructor(sessionId: string, options: TokenServiceOptions) { + this.sessionId = sessionId; + this.options = { + fetcher: options.fetcher, + onTokenError: options.onTokenError ?? (() => {}), + onTokenResolved: options.onTokenResolved ?? (() => {}), + refreshBufferSeconds: options.refreshBufferSeconds ?? DEFAULT_REFRESH_BUFFER_SECONDS, + retryConfig: { ...DEFAULT_RETRY_CONFIG, ...options.retryConfig }, + }; + } + + backgroundRefresh(cacheKey: TokenCacheKey, options?: GetTokenOptions): void { + const currentState = this.cache.get(cacheKey); + if (currentState?.status !== 'valid') { + return; + } + + const promise = this.doFetchWithRetry(cacheKey, options); + + this.cache.set(cacheKey, { + currentToken: currentState.token, + promise, + startedAt: Date.now(), + status: 'refreshing', + }); + + promise + .then(token => { + if (this.destroyed) { + return; + } + + const expiresAt = this.getTokenExpiry(token); + const refreshTimeoutId = this.scheduleProactiveRefresh(cacheKey, expiresAt, options); + + this.cache.set(cacheKey, { + expiresAt, + refreshTimeoutId, + status: 'valid', + token, + }); + + this.options.onTokenResolved(token, cacheKey); + }) + .catch(error => { + if (this.destroyed) { + return; + } + + const refreshingState = this.cache.get(cacheKey); + if (refreshingState?.status === 'refreshing') { + this.cache.set(cacheKey, { + expiresAt: this.getTokenExpiry(refreshingState.currentToken), + refreshTimeoutId: null, + status: 'valid', + token: refreshingState.currentToken, + }); + } + + console.warn('[TokenService] Background refresh failed:', error); + }); + } + + buildCacheKey(template?: string, organizationId?: string | null): TokenCacheKey { + const orgPart = organizationId ?? '__personal__'; + const templatePart = template ?? '__session__'; + return `${this.sessionId}:${templatePart}:${orgPart}`; + } + + destroy(): void { + this.destroyed = true; + this.invalidate(); + } + + private async doFetchWithRetry(cacheKey: TokenCacheKey, options?: GetTokenOptions): Promise { + const { organizationId, template } = this.parseCacheKey(cacheKey); + const config = this.options.retryConfig; + + let delay = config.initialDelayMs; + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) { + try { + return await this.options.fetcher({ + organizationId, + sessionId: this.sessionId, + template, + }); + } catch (error) { + lastError = error as Error; + + if (!config.shouldRetry(lastError) || attempt === config.maxRetries) { + throw lastError; + } + + await new Promise(resolve => setTimeout(resolve, delay)); + delay = Math.min(delay * config.factor, config.maxDelayMs); + } + } + + throw lastError!; + } + + private async fetchToken(cacheKey: TokenCacheKey, options?: GetTokenOptions): Promise { + const promise = this.doFetchWithRetry(cacheKey, options); + + this.cache.set(cacheKey, { + promise, + startedAt: Date.now(), + status: 'fetching', + }); + + try { + const token = await promise; + + if (this.destroyed) { + return token.getRawString() || null; + } + + const expiresAt = this.getTokenExpiry(token); + const refreshTimeoutId = this.scheduleProactiveRefresh(cacheKey, expiresAt, options); + + this.cache.set(cacheKey, { + expiresAt, + refreshTimeoutId, + status: 'valid', + token, + }); + + this.options.onTokenResolved(token, cacheKey); + + return token.getRawString() || null; + } catch (error) { + if (this.destroyed) { + throw error; + } + + this.cache.set(cacheKey, { + error: error as Error, + failedAt: Date.now(), + nextRetryAt: null, + retryCount: 0, + status: 'error', + }); + + this.options.onTokenError(error as Error, cacheKey); + throw error; + } + } + + getState(cacheKey: TokenCacheKey): TokenState { + return this.cache.get(cacheKey) ?? { status: 'idle' }; + } + + async getToken(options?: GetTokenOptions): Promise { + if (this.destroyed) { + throw new Error('TokenService has been destroyed'); + } + + const cacheKey = this.buildCacheKey(options?.template, options?.organizationId ?? null); + const state = this.cache.get(cacheKey) ?? { status: 'idle' }; + + if (options?.skipCache) { + return this.fetchToken(cacheKey, options); + } + + switch (state.status) { + case 'idle': + return this.fetchToken(cacheKey, options); + case 'fetching': + return state.promise.then(token => token.getRawString() || null); + case 'valid': { + const leeway = (options?.leewayInSeconds ?? 0) * 1000; + const now = Date.now(); + const effectiveExpiry = state.expiresAt - leeway; + + if (now >= effectiveExpiry) { + return this.fetchToken(cacheKey, options); + } + + const refreshThreshold = state.expiresAt - this.options.refreshBufferSeconds * 1000; + if (now >= refreshThreshold && !state.refreshTimeoutId) { + this.backgroundRefresh(cacheKey, options); + } + + return state.token.getRawString() || null; + } + case 'refreshing': + return state.currentToken.getRawString() || null; + case 'error': { + if (state.nextRetryAt && state.nextRetryAt - Date.now() < 1000) { + const waitMs = Math.max(state.nextRetryAt - Date.now() + 100, 0); + await new Promise(resolve => setTimeout(resolve, waitMs)); + return this.getToken(options); + } + return this.fetchToken(cacheKey, options); + } + default: + return null; + } + } + + private getTokenExpiry(token: Token): number { + const claims = token.jwt?.claims; + if (claims?.exp) { + return claims.exp * 1000; + } + return Date.now() + 60000; + } + + hasValidToken(cacheKey: TokenCacheKey): boolean { + const state = this.cache.get(cacheKey); + if (!state) { + return false; + } + + if (state.status === 'valid') { + return Date.now() < state.expiresAt; + } + if (state.status === 'refreshing') { + return Date.now() < this.getTokenExpiry(state.currentToken); + } + return false; + } + + ingestToken(token: Token, cacheKey: TokenCacheKey): void { + const existingState = this.cache.get(cacheKey); + if (existingState?.status === 'valid' && existingState.refreshTimeoutId) { + clearTimeout(existingState.refreshTimeoutId); + } + + const expiresAt = this.getTokenExpiry(token); + const refreshTimeoutId = this.scheduleProactiveRefresh(cacheKey, expiresAt); + + this.cache.set(cacheKey, { + expiresAt, + refreshTimeoutId, + status: 'valid', + token, + }); + } + + invalidate(cacheKey?: TokenCacheKey): void { + if (cacheKey) { + const state = this.cache.get(cacheKey); + if (state?.status === 'valid' && state.refreshTimeoutId) { + clearTimeout(state.refreshTimeoutId); + } + this.cache.delete(cacheKey); + return; + } + + for (const [key, state] of this.cache) { + if (state.status === 'valid' && state.refreshTimeoutId) { + clearTimeout(state.refreshTimeoutId); + } + this.cache.delete(key); + } + } + + private parseCacheKey(cacheKey: TokenCacheKey): { organizationId?: string | null; template?: string } { + const [, templatePart, orgPart] = cacheKey.split(':'); + return { + organizationId: orgPart === '__personal__' ? null : orgPart, + template: templatePart === '__session__' ? undefined : templatePart, + }; + } + + private scheduleProactiveRefresh( + cacheKey: TokenCacheKey, + expiresAt: number, + options?: GetTokenOptions, + ): ReturnType | null { + const refreshAt = expiresAt - this.options.refreshBufferSeconds * 1000; + const delay = refreshAt - Date.now(); + + if (delay <= 0) { + return null; + } + + return setTimeout(() => { + if (!this.destroyed) { + this.backgroundRefresh(cacheKey, options); + } + }, delay); + } +} diff --git a/packages/clerk-js/src/core/services/__tests__/TokenService.test.ts b/packages/clerk-js/src/core/services/__tests__/TokenService.test.ts new file mode 100644 index 00000000000..3533a9ff07c --- /dev/null +++ b/packages/clerk-js/src/core/services/__tests__/TokenService.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../resources/Token', () => { + class MockToken { + jwt?: { claims: any }; + + constructor(data: any) { + const raw = data?.jwt; + if (raw) { + const payload = raw.split('.')[1] || ''; + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); + const paddingLength = normalized.length % 4 === 0 ? 0 : 4 - (normalized.length % 4); + const decoded = Buffer.from(normalized + '='.repeat(paddingLength), 'base64').toString(); + const claims = JSON.parse(decoded); + this.jwt = { claims: { ...claims, __raw: raw } }; + } + } + + getRawString = () => { + return this.jwt?.claims?.__raw || ''; + }; + } + + return { Token: MockToken }; +}); + +import { Token } from '../../resources/Token'; +import { TokenService } from '../TokenService'; + +const createJwt = (iat: number, exp: number) => { + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ exp, iat, sid: 'sess_123' })).toString('base64url'); + return `${header}.${payload}.signature`; +}; + +const createToken = (expiresInSeconds: number, issuedAtMs = Date.now()) => { + const iat = Math.floor(issuedAtMs / 1000); + const exp = iat + expiresInSeconds; + return new Token({ jwt: createJwt(iat, exp), object: 'token' }); +}; + +describe('TokenService', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(0)); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); + }); + + it('transitions idle -> fetching -> valid on successful fetch', async () => { + const fetcher = vi.fn().mockResolvedValue(createToken(60)); + const service = new TokenService('sess_1', { fetcher }); + + const tokenPromise = service.getToken(); + expect(service.getState(service.buildCacheKey())).toMatchObject({ status: 'fetching' }); + + const token = await tokenPromise; + expect(token).toBeTruthy(); + expect(service.getState(service.buildCacheKey()).status).toBe('valid'); + }); + + it('transitions idle -> fetching -> error on failed fetch', async () => { + const failingError = Object.assign(new Error('fail'), { status: 400 }); + const fetcher = vi.fn().mockRejectedValue(failingError); + const service = new TokenService('sess_1', { fetcher }); + + await expect(service.getToken()).rejects.toThrow('fail'); + expect(service.getState(service.buildCacheKey()).status).toBe('error'); + }); + + it('transitions valid -> refreshing -> valid on background refresh success', async () => { + const firstToken = createToken(30); + const refreshedToken = createToken(60); + const fetcher = vi.fn().mockResolvedValueOnce(firstToken).mockResolvedValueOnce(refreshedToken); + const service = new TokenService('sess_1', { fetcher, refreshBufferSeconds: 5 }); + + await service.getToken(); + expect(service.getState(service.buildCacheKey()).status).toBe('valid'); + + service.backgroundRefresh(service.buildCacheKey()); + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2)); + + expect(fetcher).toHaveBeenCalledTimes(2); + await vi.waitFor(() => expect(service.getState(service.buildCacheKey()).status).toBe('valid')); + const state = service.getState(service.buildCacheKey()); + if (state.status === 'valid') { + expect(state.token.getRawString()).toBe(refreshedToken.getRawString()); + } + service.destroy(); + }); + + it('keeps existing token when background refresh fails', async () => { + const firstToken = createToken(30); + const fetcher = vi + .fn() + .mockResolvedValueOnce(firstToken) + .mockRejectedValueOnce(Object.assign(new Error('refresh-fail'), { status: 400 })); + const service = new TokenService('sess_1', { fetcher, refreshBufferSeconds: 5 }); + + const rawToken = await service.getToken(); + service.backgroundRefresh(service.buildCacheKey()); + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2)); + + expect(fetcher).toHaveBeenCalledTimes(2); + await vi.waitFor(() => expect(service.getState(service.buildCacheKey()).status).toBe('valid')); + expect(await service.getToken()).toBe(rawToken); + service.destroy(); + }); + + it('coalesces concurrent getToken calls for same cache key', async () => { + let resolveToken: (value: Token) => void; + const fetcher = vi.fn().mockImplementation( + () => + new Promise(resolve => { + resolveToken = resolve; + }), + ); + const service = new TokenService('sess_1', { fetcher }); + + const firstCall = service.getToken(); + const secondCall = service.getToken(); + + resolveToken!(createToken(60)); + const [firstToken, secondToken] = await Promise.all([firstCall, secondCall]); + + expect(fetcher).toHaveBeenCalledTimes(1); + expect(firstToken).toBe(secondToken); + }); + + it('does not coalesce calls with different cache keys', async () => { + const fetcher = vi.fn().mockResolvedValue(createToken(60)); + const service = new TokenService('sess_1', { fetcher }); + + await Promise.all([service.getToken({ template: 'a' }), service.getToken({ template: 'b' })]); + + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('returns cached token when valid', async () => { + const fetcher = vi.fn().mockResolvedValue(createToken(60)); + const service = new TokenService('sess_1', { fetcher }); + + const first = await service.getToken(); + const second = await service.getToken(); + + expect(fetcher).toHaveBeenCalledTimes(1); + expect(first).toBe(second); + }); + + it('respects leewayInSeconds and refetches when within leeway', async () => { + const fetcher = vi.fn().mockResolvedValue(createToken(20)); + const service = new TokenService('sess_1', { fetcher }); + + await service.getToken(); + await service.getToken({ leewayInSeconds: 30 }); + + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('fetches new token when skipCache is true', async () => { + const fetcher = vi.fn().mockResolvedValue(createToken(60)); + const service = new TokenService('sess_1', { fetcher }); + + await service.getToken({ skipCache: true }); + await service.getToken({ skipCache: true }); + + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('invalidates specific cache key', async () => { + const fetcher = vi.fn().mockResolvedValue(createToken(60)); + const service = new TokenService('sess_1', { fetcher }); + + await service.getToken(); + const cacheKey = service.buildCacheKey(); + expect(service.hasValidToken(cacheKey)).toBe(true); + + service.invalidate(cacheKey); + expect(service.hasValidToken(cacheKey)).toBe(false); + }); + + it('schedules proactive refresh before expiry', async () => { + const fetcher = vi.fn().mockResolvedValue(createToken(20)); + const service = new TokenService('sess_1', { fetcher, refreshBufferSeconds: 5 }); + + await service.getToken(); + const state = service.getState(service.buildCacheKey()); + expect(state.status).toBe('valid'); + if (state.status === 'valid') { + expect(state.refreshTimeoutId).toBeTruthy(); + } + }); + + it('retries on transient errors', async () => { + const fetcher = vi.fn().mockRejectedValueOnce(new Error('transient')).mockResolvedValue(createToken(60)); + const service = new TokenService('sess_1', { fetcher }); + + const tokenPromise = service.getToken(); + await vi.advanceTimersByTimeAsync(3000); + const token = await tokenPromise; + + expect(token).toBeTruthy(); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('does not retry on 4xx errors', async () => { + const error = Object.assign(new Error('4xx'), { status: 401 }); + const fetcher = vi.fn().mockRejectedValue(error); + const service = new TokenService('sess_1', { fetcher }); + + await expect(service.getToken()).rejects.toThrow('4xx'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('respects maxRetries', async () => { + const fetcher = vi.fn().mockRejectedValue(new Error('fail')); + const service = new TokenService('sess_1', { + fetcher, + retryConfig: { + factor: 1, + initialDelayMs: 10, + maxDelayMs: 20, + maxRetries: 2, + shouldRetry: () => true, + }, + }); + + const tokenPromise = service.getToken().catch(() => null); + await vi.runAllTimersAsync(); + await tokenPromise; + + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it('ingests external token', () => { + const service = new TokenService('sess_1', { + fetcher: vi.fn(), + }); + const cacheKey = service.buildCacheKey(); + const token = createToken(60); + + service.ingestToken(token, cacheKey); + + expect(service.hasValidToken(cacheKey)).toBe(true); + }); + + it('clears timers and invalidates on destroy', async () => { + const fetcher = vi.fn().mockResolvedValue(createToken(60)); + const service = new TokenService('sess_1', { fetcher }); + + await service.getToken(); + service.destroy(); + + await expect(service.getToken()).rejects.toThrowError('TokenService has been destroyed'); + }); +}); diff --git a/packages/clerk-js/src/global.d.ts b/packages/clerk-js/src/global.d.ts index 8ecd6ad7635..b08707ae7bc 100644 --- a/packages/clerk-js/src/global.d.ts +++ b/packages/clerk-js/src/global.d.ts @@ -13,6 +13,7 @@ const __DEV__: boolean; const __BUILD_DISABLE_RHC__: string; const __BUILD_VARIANT_CHANNEL__: boolean; const __BUILD_VARIANT_CHIPS__: boolean; +const __BUILD_VARIANT_EXPERIMENTAL__: boolean; interface Window { __internal_onBeforeSetActive: (intent?: 'sign-out') => Promise | void; diff --git a/packages/clerk-js/vitest.config.mts b/packages/clerk-js/vitest.config.mts index c74923a9bfd..5ea494a2bac 100644 --- a/packages/clerk-js/vitest.config.mts +++ b/packages/clerk-js/vitest.config.mts @@ -27,6 +27,7 @@ export default defineConfig({ define: { __BUILD_DISABLE_RHC__: JSON.stringify(false), __BUILD_VARIANT_CHIPS__: JSON.stringify(false), + __BUILD_VARIANT_EXPERIMENTAL__: JSON.stringify(false), __PKG_NAME__: JSON.stringify('@clerk/clerk-js'), __PKG_VERSION__: JSON.stringify('test'), },