diff --git a/.changeset/cool-times-divide.md b/.changeset/cool-times-divide.md new file mode 100644 index 00000000000..fe59aceafbf --- /dev/null +++ b/.changeset/cool-times-divide.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fallback to locally stored environment during an outage. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 1cbfaca43fe..ca1c4c99940 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,8 +1,8 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "581.5kB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "79.30kB" }, - { "path": "./dist/clerk.headless.js", "maxSize": "55KB" }, + { "path": "./dist/clerk.js", "maxSize": "581.65kB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "79.6kB" }, + { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, { "path": "./dist/ui-common*.js", "maxSize": "96KB" }, { "path": "./dist/vendors*.js", "maxSize": "30KB" }, { "path": "./dist/coinbase*.js", "maxSize": "35.5KB" }, diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 72d6ef7ddcc..ca6648cdc68 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -107,6 +107,7 @@ import { windowNavigate, } from '../utils'; import { assertNoLegacyProp } from '../utils/assertNoLegacyProp'; +import { CLERK_ENVIRONMENT_STORAGE_ENTRY, SafeLocalStorage } from '../utils/localStorage'; import { memoizeListenerCallback } from '../utils/memoizeStateListenerCallback'; import { RedirectUrls } from '../utils/redirectUrls'; import { AuthCookieService } from './auth/AuthCookieService'; @@ -2117,6 +2118,18 @@ export class Clerk implements ClerkInterface { .fetch({ touch: shouldTouchEnv }) .then(res => { this.updateEnvironment(res); + }) + .catch(e => { + const environmentSnapshot = SafeLocalStorage.getItem( + CLERK_ENVIRONMENT_STORAGE_ENTRY, + null, + ); + + if (!environmentSnapshot) { + throw e; + } + + this.updateEnvironment(new Environment(environmentSnapshot)); }); const initClient = async () => { @@ -2156,6 +2169,7 @@ export class Clerk implements ClerkInterface { }; const [envResult, clientResult] = await Promise.allSettled([initEnvironmentPromise, initClient()]); + if (clientResult.status === 'rejected') { const e = clientResult.reason; @@ -2170,6 +2184,8 @@ export class Clerk implements ClerkInterface { } } + await initEnvironmentPromise; + this.#authService?.setClientUatCookieForDevelopmentInstances(); if (await this.#redirectFAPIInitiatedFlow()) { @@ -2295,6 +2311,15 @@ export class Clerk implements ClerkInterface { eventBus.on(events.UserSignOut, () => { this.#broadcastChannel?.postMessage({ type: 'signout' }); }); + + eventBus.on(events.EnvironmentUpdate, () => { + // Cache the environment snapshot for 24 hours + SafeLocalStorage.setItem( + CLERK_ENVIRONMENT_STORAGE_ENTRY, + this.environment?.__internal_toSnapshot(), + 24 * 60 * 60 * 1_000, + ); + }); }; // TODO: Be more conservative about touches. Throttle, don't touch when only one user, etc diff --git a/packages/clerk-js/src/core/events.ts b/packages/clerk-js/src/core/events.ts index 7401dd91370..42181706221 100644 --- a/packages/clerk-js/src/core/events.ts +++ b/packages/clerk-js/src/core/events.ts @@ -3,6 +3,7 @@ import type { TokenResource } from '@clerk/types'; export const events = { TokenUpdate: 'token:update', UserSignOut: 'user:signOut', + EnvironmentUpdate: 'environment:update', } as const; type ClerkEvent = (typeof events)[keyof typeof events]; @@ -13,6 +14,7 @@ type TokenUpdatePayload = { token: TokenResource | null }; type EventPayload = { [events.TokenUpdate]: TokenUpdatePayload; [events.UserSignOut]: null; + [events.EnvironmentUpdate]: null; }; const createEventBus = () => { diff --git a/packages/clerk-js/src/core/resources/Environment.ts b/packages/clerk-js/src/core/resources/Environment.ts index f589088a749..af180374ec9 100644 --- a/packages/clerk-js/src/core/resources/Environment.ts +++ b/packages/clerk-js/src/core/resources/Environment.ts @@ -9,6 +9,7 @@ import type { UserSettingsResource, } from '@clerk/types'; +import { eventBus, events } from '../../core/events'; import { __experimental_CommerceSettings, AuthConfig, BaseResource, DisplayConfig, UserSettings } from './internal'; import { OrganizationSettings } from './OrganizationSettings'; @@ -53,7 +54,12 @@ export class Environment extends BaseResource implements EnvironmentResource { } fetch({ touch, fetchMaxTries }: { touch: boolean; fetchMaxTries?: number } = { touch: false }): Promise { - return touch ? this._basePatch({}) : this._baseGet({ fetchMaxTries }); + const promise = touch ? this._basePatch({}) : this._baseGet({ fetchMaxTries }); + + return promise.then(data => { + eventBus.dispatch(events.EnvironmentUpdate, null); + return data; + }); } isDevelopmentOrStaging = (): boolean => { diff --git a/packages/clerk-js/src/ui/hooks/index.ts b/packages/clerk-js/src/ui/hooks/index.ts index 3577faa53fb..fc7a480fcbe 100644 --- a/packages/clerk-js/src/ui/hooks/index.ts +++ b/packages/clerk-js/src/ui/hooks/index.ts @@ -9,7 +9,6 @@ export * from './useEnterpriseSSOLink'; export * from './useFetch'; export * from './useInView'; export * from './useLoadingStatus'; -export * from './useLocalStorage'; export * from './useNavigateToFlowStart'; export * from './usePassword'; export * from './usePasswordComplexity'; diff --git a/packages/clerk-js/src/ui/hooks/useLocalStorage.ts b/packages/clerk-js/src/ui/hooks/useLocalStorage.ts deleted file mode 100644 index 9b0e68e49a7..00000000000 --- a/packages/clerk-js/src/ui/hooks/useLocalStorage.ts +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; - -export function useLocalStorage(key: string, initialValue: T) { - key = 'clerk:' + key; - const [storedValue, setStoredValue] = React.useState(() => { - if (typeof window === 'undefined') { - return initialValue; - } - - try { - const item = window.localStorage.getItem(key); - return item ? JSON.parse(item) : initialValue; - } catch { - return initialValue; - } - }); - - const setValue = React.useCallback((value: ((stored: T) => T) | T) => { - if (typeof window === 'undefined') { - console.warn(`Tried setting localStorage key "${key}" even though environment is not a client`); - } - - try { - const valueToStore = value instanceof Function ? value(storedValue) : value; - setStoredValue(valueToStore); - window.localStorage.setItem(key, JSON.stringify(valueToStore)); - } catch (error) { - console.error(error); - } - }, []); - - return [storedValue, setValue] as const; -} diff --git a/packages/clerk-js/src/utils/__tests__/localStorage.test.ts b/packages/clerk-js/src/utils/__tests__/localStorage.test.ts new file mode 100644 index 00000000000..792f0acfa7f --- /dev/null +++ b/packages/clerk-js/src/utils/__tests__/localStorage.test.ts @@ -0,0 +1,169 @@ +import { SafeLocalStorage } from '../localStorage'; + +describe('SafeLocalStorage', () => { + let mockStorage: { [key: string]: string } = {}; + + beforeEach(() => { + mockStorage = {}; + + // Create a mock implementation of localStorage + const localStorageMock = { + getItem: (key: string) => mockStorage[key] || null, + setItem: (key: string, value: string) => { + mockStorage[key] = value; + }, + removeItem: (key: string) => { + delete mockStorage[key]; + }, + }; + + // Replace window.localStorage with our mock + Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true, + }); + }); + + afterEach(() => { + mockStorage = {}; + jest.restoreAllMocks(); + }); + + describe('setItem', () => { + it('stores value with clerk prefix', () => { + SafeLocalStorage.setItem('test', 'value'); + expect(mockStorage['__clerk_test']).toBeDefined(); + const parsed = JSON.parse(mockStorage['__clerk_test']); + expect(parsed.value).toBe('value'); + }); + + it('handles localStorage errors gracefully', () => { + Object.defineProperty(window, 'localStorage', { + value: { + setItem: () => { + throw new Error('Storage full'); + }, + }, + writable: true, + }); + + expect(() => { + SafeLocalStorage.setItem('test', 'value'); + }).not.toThrow(); + }); + + it('sets expiration when provided', () => { + jest.useFakeTimers(); + const now = Date.now(); + SafeLocalStorage.setItem('test', 'value', 1000); + + const stored = JSON.parse(mockStorage['__clerk_test']); + expect(stored.exp).toBe(now + 1000); + jest.useRealTimers(); + }); + + it('stores complex objects correctly', () => { + const complexObject = { foo: 'bar', nested: { value: 42 } }; + SafeLocalStorage.setItem('complex', complexObject); + const stored = JSON.parse(mockStorage['__clerk_complex']); + expect(stored.value).toEqual(complexObject); + }); + + it('does not set expiration when not provided', () => { + SafeLocalStorage.setItem('test', 'value'); + const stored = JSON.parse(mockStorage['__clerk_test']); + expect(stored.exp).toBeUndefined(); + }); + }); + + describe('getItem', () => { + it('retrieves stored value', () => { + SafeLocalStorage.setItem('test', 'value'); + expect(SafeLocalStorage.getItem('test', 'default')).toBe('value'); + }); + + it('retrieves stored value when not expired', () => { + SafeLocalStorage.setItem('test', 'value', 1_000); + expect(SafeLocalStorage.getItem('test', 'default')).toBe('value'); + }); + + it('returns default value when key not found', () => { + expect(SafeLocalStorage.getItem('nonexistent', 'default')).toBe('default'); + }); + + it('handles localStorage errors by returning default value', () => { + Object.defineProperty(window, 'localStorage', { + value: { + getItem: () => { + throw new Error('Storage error'); + }, + }, + writable: true, + }); + + expect(SafeLocalStorage.getItem('test', 'default')).toBe('default'); + }); + + it('returns default value and removes item when expired', () => { + jest.useFakeTimers(); + SafeLocalStorage.setItem('test', 'value', 1_000); + + // Advance time beyond expiration + jest.advanceTimersByTime(1_001); + + expect(SafeLocalStorage.getItem('test', 'default')).toBe('default'); + expect(mockStorage['__clerk_test']).toBeUndefined(); + jest.useRealTimers(); + }); + + it('handles malformed JSON data by returning default value', () => { + mockStorage['__clerk_malformed'] = 'not-json-data'; + expect(SafeLocalStorage.getItem('malformed', 'default')).toBe('default'); + }); + + it('handles empty stored value by returning default', () => { + mockStorage['__clerk_empty'] = JSON.stringify({ value: null }); + expect(SafeLocalStorage.getItem('empty', 'default')).toBe('default'); + }); + + it('retrieves complex objects correctly', () => { + const complexObject = { foo: 'bar', nested: { value: 42 } }; + SafeLocalStorage.setItem('complex', complexObject); + expect(SafeLocalStorage.getItem('complex', {})).toEqual(complexObject); + }); + + it('handles edge case with zero as stored value', () => { + SafeLocalStorage.setItem('zero', 0); + expect(SafeLocalStorage.getItem('zero', 1)).toBe(0); + }); + }); + + describe('removeItem', () => { + it('removes item with clerk prefix', () => { + SafeLocalStorage.setItem('test', 'value'); + expect(mockStorage['__clerk_test']).toBeDefined(); + SafeLocalStorage.removeItem('test'); + expect(mockStorage['__clerk_test']).toBeUndefined(); + }); + + it('handles localStorage errors gracefully', () => { + Object.defineProperty(window, 'localStorage', { + value: { + removeItem: () => { + throw new Error('Storage error'); + }, + }, + writable: true, + }); + + expect(() => { + SafeLocalStorage.removeItem('test'); + }).not.toThrow(); + }); + + it('does nothing when removing non-existent item', () => { + SafeLocalStorage.removeItem('nonexistent'); + expect(mockStorage['__clerk_nonexistent']).toBeUndefined(); + }); + }); +}); diff --git a/packages/clerk-js/src/utils/localStorage.ts b/packages/clerk-js/src/utils/localStorage.ts new file mode 100644 index 00000000000..9b517967e36 --- /dev/null +++ b/packages/clerk-js/src/utils/localStorage.ts @@ -0,0 +1,66 @@ +const CLERK_PREFIX = '__clerk_'; + +export const CLERK_ENVIRONMENT_STORAGE_ENTRY = 'environment'; + +interface StorageEntry { + value: T; + exp?: number; +} + +const serialize = JSON.stringify; +const parse = JSON.parse; + +/** + * Safe wrapper around localStorage that automatically prefixes keys with 'clerk_' + * and handles potential errors and entry expiration + */ +export class SafeLocalStorage { + private static _key(key: string): string { + return `${CLERK_PREFIX}${key}`; + } + + private static isExpired(entry: StorageEntry): boolean { + return !!entry.exp && Date.now() > entry.exp; + } + + static setItem(key: string, value: unknown, expiresInMs?: number): void { + try { + const entry: StorageEntry = { + value, + ...(expiresInMs && { exp: Date.now() + expiresInMs }), + }; + window.localStorage.setItem(this._key(key), serialize(entry)); + } catch { + // noop + } + } + + static getItem(key: string, defaultValue: T): T { + try { + const item = window.localStorage.getItem(this._key(key)); + if (!item) return defaultValue; + const entry = parse(item) as unknown as StorageEntry | undefined | null; + + if (!entry) { + return defaultValue; + } + + if (this.isExpired(entry)) { + this.removeItem(key); + return defaultValue; + } + + return entry?.value ?? defaultValue; + } catch { + return defaultValue; + } + } + + static removeItem(key: string): void { + try { + window.localStorage.removeItem(this._key(key)); + } catch { + // noop + } + } +}