Skip to content

chore(clerk-js): Use local environment on outage #5420

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Mar 26, 2025
Merged
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
5 changes: 5 additions & 0 deletions .changeset/cool-times-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fallback to locally stored environment during an outage.
6 changes: 3 additions & 3 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -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" },
Expand Down
25 changes: 25 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2117,6 +2118,18 @@ export class Clerk implements ClerkInterface {
.fetch({ touch: shouldTouchEnv })
.then(res => {
this.updateEnvironment(res);
})
.catch(e => {
const environmentSnapshot = SafeLocalStorage.getItem<EnvironmentJSONSnapshot | null>(
CLERK_ENVIRONMENT_STORAGE_ENTRY,
null,
);

if (!environmentSnapshot) {
throw e;
}

this.updateEnvironment(new Environment(environmentSnapshot));
Comment on lines +2122 to +2132
Copy link
Member

@jacekradko jacekradko Mar 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering if this logic shouldn't be contained within the EnvironmentResource class. Seems like it could simplify the implementation overall

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a bit confused here, are you advocating for this to be included in the Environment class because currently it is not.

If that is the case, I'd suggest doing this in another follow up PR, which will improve the overall architecture which would handle this for non-standard browsers (e.g. Expo).

Copy link
Member

@jacekradko jacekradko Mar 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@panteliselef Yes, I think having this logic be encapsulated in the EnvrionmentResource class would be preferrable. No issue with doing it as a follow up

});

const initClient = async () => {
Expand Down Expand Up @@ -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;

Expand All @@ -2170,6 +2184,8 @@ export class Clerk implements ClerkInterface {
}
}

await initEnvironmentPromise;

this.#authService?.setClientUatCookieForDevelopmentInstances();

if (await this.#redirectFAPIInitiatedFlow()) {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/core/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -13,6 +14,7 @@ type TokenUpdatePayload = { token: TokenResource | null };
type EventPayload = {
[events.TokenUpdate]: TokenUpdatePayload;
[events.UserSignOut]: null;
[events.EnvironmentUpdate]: null;
};

const createEventBus = () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/clerk-js/src/core/resources/Environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -53,7 +54,12 @@ export class Environment extends BaseResource implements EnvironmentResource {
}

fetch({ touch, fetchMaxTries }: { touch: boolean; fetchMaxTries?: number } = { touch: false }): Promise<Environment> {
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 => {
Expand Down
1 change: 0 additions & 1 deletion packages/clerk-js/src/ui/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
33 changes: 0 additions & 33 deletions packages/clerk-js/src/ui/hooks/useLocalStorage.ts

This file was deleted.

169 changes: 169 additions & 0 deletions packages/clerk-js/src/utils/__tests__/localStorage.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
66 changes: 66 additions & 0 deletions packages/clerk-js/src/utils/localStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const CLERK_PREFIX = '__clerk_';

export const CLERK_ENVIRONMENT_STORAGE_ENTRY = 'environment';

interface StorageEntry<T> {
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<unknown>): boolean {
return !!entry.exp && Date.now() > entry.exp;
}

static setItem(key: string, value: unknown, expiresInMs?: number): void {
try {
const entry: StorageEntry<unknown> = {
value,
...(expiresInMs && { exp: Date.now() + expiresInMs }),
};
window.localStorage.setItem(this._key(key), serialize(entry));
} catch {
// noop
}
}

static getItem<T>(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<T> | 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
}
}
}