Skip to content

Commit 98286dc

Browse files
authored
chore(clerk-js): Use local environment on outage (#5420)
1 parent e20fb6b commit 98286dc

File tree

9 files changed

+277
-38
lines changed

9 files changed

+277
-38
lines changed

.changeset/cool-times-divide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Fallback to locally stored environment during an outage.

packages/clerk-js/bundlewatch.config.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"files": [
3-
{ "path": "./dist/clerk.js", "maxSize": "581.5kB" },
4-
{ "path": "./dist/clerk.browser.js", "maxSize": "79.30kB" },
5-
{ "path": "./dist/clerk.headless.js", "maxSize": "55KB" },
3+
{ "path": "./dist/clerk.js", "maxSize": "581.65kB" },
4+
{ "path": "./dist/clerk.browser.js", "maxSize": "79.6kB" },
5+
{ "path": "./dist/clerk.headless*.js", "maxSize": "55KB" },
66
{ "path": "./dist/ui-common*.js", "maxSize": "96KB" },
77
{ "path": "./dist/vendors*.js", "maxSize": "30KB" },
88
{ "path": "./dist/coinbase*.js", "maxSize": "35.5KB" },

packages/clerk-js/src/core/clerk.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ import {
107107
windowNavigate,
108108
} from '../utils';
109109
import { assertNoLegacyProp } from '../utils/assertNoLegacyProp';
110+
import { CLERK_ENVIRONMENT_STORAGE_ENTRY, SafeLocalStorage } from '../utils/localStorage';
110111
import { memoizeListenerCallback } from '../utils/memoizeStateListenerCallback';
111112
import { RedirectUrls } from '../utils/redirectUrls';
112113
import { AuthCookieService } from './auth/AuthCookieService';
@@ -2117,6 +2118,18 @@ export class Clerk implements ClerkInterface {
21172118
.fetch({ touch: shouldTouchEnv })
21182119
.then(res => {
21192120
this.updateEnvironment(res);
2121+
})
2122+
.catch(e => {
2123+
const environmentSnapshot = SafeLocalStorage.getItem<EnvironmentJSONSnapshot | null>(
2124+
CLERK_ENVIRONMENT_STORAGE_ENTRY,
2125+
null,
2126+
);
2127+
2128+
if (!environmentSnapshot) {
2129+
throw e;
2130+
}
2131+
2132+
this.updateEnvironment(new Environment(environmentSnapshot));
21202133
});
21212134

21222135
const initClient = async () => {
@@ -2156,6 +2169,7 @@ export class Clerk implements ClerkInterface {
21562169
};
21572170

21582171
const [envResult, clientResult] = await Promise.allSettled([initEnvironmentPromise, initClient()]);
2172+
21592173
if (clientResult.status === 'rejected') {
21602174
const e = clientResult.reason;
21612175

@@ -2170,6 +2184,8 @@ export class Clerk implements ClerkInterface {
21702184
}
21712185
}
21722186

2187+
await initEnvironmentPromise;
2188+
21732189
this.#authService?.setClientUatCookieForDevelopmentInstances();
21742190

21752191
if (await this.#redirectFAPIInitiatedFlow()) {
@@ -2295,6 +2311,15 @@ export class Clerk implements ClerkInterface {
22952311
eventBus.on(events.UserSignOut, () => {
22962312
this.#broadcastChannel?.postMessage({ type: 'signout' });
22972313
});
2314+
2315+
eventBus.on(events.EnvironmentUpdate, () => {
2316+
// Cache the environment snapshot for 24 hours
2317+
SafeLocalStorage.setItem(
2318+
CLERK_ENVIRONMENT_STORAGE_ENTRY,
2319+
this.environment?.__internal_toSnapshot(),
2320+
24 * 60 * 60 * 1_000,
2321+
);
2322+
});
22982323
};
22992324

23002325
// TODO: Be more conservative about touches. Throttle, don't touch when only one user, etc

packages/clerk-js/src/core/events.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { TokenResource } from '@clerk/types';
33
export const events = {
44
TokenUpdate: 'token:update',
55
UserSignOut: 'user:signOut',
6+
EnvironmentUpdate: 'environment:update',
67
} as const;
78

89
type ClerkEvent = (typeof events)[keyof typeof events];
@@ -13,6 +14,7 @@ type TokenUpdatePayload = { token: TokenResource | null };
1314
type EventPayload = {
1415
[events.TokenUpdate]: TokenUpdatePayload;
1516
[events.UserSignOut]: null;
17+
[events.EnvironmentUpdate]: null;
1618
};
1719

1820
const createEventBus = () => {

packages/clerk-js/src/core/resources/Environment.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
UserSettingsResource,
1010
} from '@clerk/types';
1111

12+
import { eventBus, events } from '../../core/events';
1213
import { __experimental_CommerceSettings, AuthConfig, BaseResource, DisplayConfig, UserSettings } from './internal';
1314
import { OrganizationSettings } from './OrganizationSettings';
1415

@@ -53,7 +54,12 @@ export class Environment extends BaseResource implements EnvironmentResource {
5354
}
5455

5556
fetch({ touch, fetchMaxTries }: { touch: boolean; fetchMaxTries?: number } = { touch: false }): Promise<Environment> {
56-
return touch ? this._basePatch({}) : this._baseGet({ fetchMaxTries });
57+
const promise = touch ? this._basePatch({}) : this._baseGet({ fetchMaxTries });
58+
59+
return promise.then(data => {
60+
eventBus.dispatch(events.EnvironmentUpdate, null);
61+
return data;
62+
});
5763
}
5864

5965
isDevelopmentOrStaging = (): boolean => {

packages/clerk-js/src/ui/hooks/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ export * from './useEnterpriseSSOLink';
99
export * from './useFetch';
1010
export * from './useInView';
1111
export * from './useLoadingStatus';
12-
export * from './useLocalStorage';
1312
export * from './useNavigateToFlowStart';
1413
export * from './usePassword';
1514
export * from './usePasswordComplexity';

packages/clerk-js/src/ui/hooks/useLocalStorage.ts

Lines changed: 0 additions & 33 deletions
This file was deleted.
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { SafeLocalStorage } from '../localStorage';
2+
3+
describe('SafeLocalStorage', () => {
4+
let mockStorage: { [key: string]: string } = {};
5+
6+
beforeEach(() => {
7+
mockStorage = {};
8+
9+
// Create a mock implementation of localStorage
10+
const localStorageMock = {
11+
getItem: (key: string) => mockStorage[key] || null,
12+
setItem: (key: string, value: string) => {
13+
mockStorage[key] = value;
14+
},
15+
removeItem: (key: string) => {
16+
delete mockStorage[key];
17+
},
18+
};
19+
20+
// Replace window.localStorage with our mock
21+
Object.defineProperty(window, 'localStorage', {
22+
value: localStorageMock,
23+
writable: true,
24+
});
25+
});
26+
27+
afterEach(() => {
28+
mockStorage = {};
29+
jest.restoreAllMocks();
30+
});
31+
32+
describe('setItem', () => {
33+
it('stores value with clerk prefix', () => {
34+
SafeLocalStorage.setItem('test', 'value');
35+
expect(mockStorage['__clerk_test']).toBeDefined();
36+
const parsed = JSON.parse(mockStorage['__clerk_test']);
37+
expect(parsed.value).toBe('value');
38+
});
39+
40+
it('handles localStorage errors gracefully', () => {
41+
Object.defineProperty(window, 'localStorage', {
42+
value: {
43+
setItem: () => {
44+
throw new Error('Storage full');
45+
},
46+
},
47+
writable: true,
48+
});
49+
50+
expect(() => {
51+
SafeLocalStorage.setItem('test', 'value');
52+
}).not.toThrow();
53+
});
54+
55+
it('sets expiration when provided', () => {
56+
jest.useFakeTimers();
57+
const now = Date.now();
58+
SafeLocalStorage.setItem('test', 'value', 1000);
59+
60+
const stored = JSON.parse(mockStorage['__clerk_test']);
61+
expect(stored.exp).toBe(now + 1000);
62+
jest.useRealTimers();
63+
});
64+
65+
it('stores complex objects correctly', () => {
66+
const complexObject = { foo: 'bar', nested: { value: 42 } };
67+
SafeLocalStorage.setItem('complex', complexObject);
68+
const stored = JSON.parse(mockStorage['__clerk_complex']);
69+
expect(stored.value).toEqual(complexObject);
70+
});
71+
72+
it('does not set expiration when not provided', () => {
73+
SafeLocalStorage.setItem('test', 'value');
74+
const stored = JSON.parse(mockStorage['__clerk_test']);
75+
expect(stored.exp).toBeUndefined();
76+
});
77+
});
78+
79+
describe('getItem', () => {
80+
it('retrieves stored value', () => {
81+
SafeLocalStorage.setItem('test', 'value');
82+
expect(SafeLocalStorage.getItem('test', 'default')).toBe('value');
83+
});
84+
85+
it('retrieves stored value when not expired', () => {
86+
SafeLocalStorage.setItem('test', 'value', 1_000);
87+
expect(SafeLocalStorage.getItem('test', 'default')).toBe('value');
88+
});
89+
90+
it('returns default value when key not found', () => {
91+
expect(SafeLocalStorage.getItem('nonexistent', 'default')).toBe('default');
92+
});
93+
94+
it('handles localStorage errors by returning default value', () => {
95+
Object.defineProperty(window, 'localStorage', {
96+
value: {
97+
getItem: () => {
98+
throw new Error('Storage error');
99+
},
100+
},
101+
writable: true,
102+
});
103+
104+
expect(SafeLocalStorage.getItem('test', 'default')).toBe('default');
105+
});
106+
107+
it('returns default value and removes item when expired', () => {
108+
jest.useFakeTimers();
109+
SafeLocalStorage.setItem('test', 'value', 1_000);
110+
111+
// Advance time beyond expiration
112+
jest.advanceTimersByTime(1_001);
113+
114+
expect(SafeLocalStorage.getItem('test', 'default')).toBe('default');
115+
expect(mockStorage['__clerk_test']).toBeUndefined();
116+
jest.useRealTimers();
117+
});
118+
119+
it('handles malformed JSON data by returning default value', () => {
120+
mockStorage['__clerk_malformed'] = 'not-json-data';
121+
expect(SafeLocalStorage.getItem('malformed', 'default')).toBe('default');
122+
});
123+
124+
it('handles empty stored value by returning default', () => {
125+
mockStorage['__clerk_empty'] = JSON.stringify({ value: null });
126+
expect(SafeLocalStorage.getItem('empty', 'default')).toBe('default');
127+
});
128+
129+
it('retrieves complex objects correctly', () => {
130+
const complexObject = { foo: 'bar', nested: { value: 42 } };
131+
SafeLocalStorage.setItem('complex', complexObject);
132+
expect(SafeLocalStorage.getItem('complex', {})).toEqual(complexObject);
133+
});
134+
135+
it('handles edge case with zero as stored value', () => {
136+
SafeLocalStorage.setItem('zero', 0);
137+
expect(SafeLocalStorage.getItem('zero', 1)).toBe(0);
138+
});
139+
});
140+
141+
describe('removeItem', () => {
142+
it('removes item with clerk prefix', () => {
143+
SafeLocalStorage.setItem('test', 'value');
144+
expect(mockStorage['__clerk_test']).toBeDefined();
145+
SafeLocalStorage.removeItem('test');
146+
expect(mockStorage['__clerk_test']).toBeUndefined();
147+
});
148+
149+
it('handles localStorage errors gracefully', () => {
150+
Object.defineProperty(window, 'localStorage', {
151+
value: {
152+
removeItem: () => {
153+
throw new Error('Storage error');
154+
},
155+
},
156+
writable: true,
157+
});
158+
159+
expect(() => {
160+
SafeLocalStorage.removeItem('test');
161+
}).not.toThrow();
162+
});
163+
164+
it('does nothing when removing non-existent item', () => {
165+
SafeLocalStorage.removeItem('nonexistent');
166+
expect(mockStorage['__clerk_nonexistent']).toBeUndefined();
167+
});
168+
});
169+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
const CLERK_PREFIX = '__clerk_';
2+
3+
export const CLERK_ENVIRONMENT_STORAGE_ENTRY = 'environment';
4+
5+
interface StorageEntry<T> {
6+
value: T;
7+
exp?: number;
8+
}
9+
10+
const serialize = JSON.stringify;
11+
const parse = JSON.parse;
12+
13+
/**
14+
* Safe wrapper around localStorage that automatically prefixes keys with 'clerk_'
15+
* and handles potential errors and entry expiration
16+
*/
17+
export class SafeLocalStorage {
18+
private static _key(key: string): string {
19+
return `${CLERK_PREFIX}${key}`;
20+
}
21+
22+
private static isExpired(entry: StorageEntry<unknown>): boolean {
23+
return !!entry.exp && Date.now() > entry.exp;
24+
}
25+
26+
static setItem(key: string, value: unknown, expiresInMs?: number): void {
27+
try {
28+
const entry: StorageEntry<unknown> = {
29+
value,
30+
...(expiresInMs && { exp: Date.now() + expiresInMs }),
31+
};
32+
window.localStorage.setItem(this._key(key), serialize(entry));
33+
} catch {
34+
// noop
35+
}
36+
}
37+
38+
static getItem<T>(key: string, defaultValue: T): T {
39+
try {
40+
const item = window.localStorage.getItem(this._key(key));
41+
if (!item) return defaultValue;
42+
const entry = parse(item) as unknown as StorageEntry<T> | undefined | null;
43+
44+
if (!entry) {
45+
return defaultValue;
46+
}
47+
48+
if (this.isExpired(entry)) {
49+
this.removeItem(key);
50+
return defaultValue;
51+
}
52+
53+
return entry?.value ?? defaultValue;
54+
} catch {
55+
return defaultValue;
56+
}
57+
}
58+
59+
static removeItem(key: string): void {
60+
try {
61+
window.localStorage.removeItem(this._key(key));
62+
} catch {
63+
// noop
64+
}
65+
}
66+
}

0 commit comments

Comments
 (0)