Skip to content

Commit 86d2199

Browse files
authored
feat(clerk-js): Improve getToken offline handling (#7598)
1 parent f284c3d commit 86d2199

17 files changed

Lines changed: 371 additions & 29 deletions

File tree

‎.changeset/sparkly-aliens-see.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@clerk/tanstack-react-start': major
3+
'@clerk/react-router': major
4+
'@clerk/clerk-js': major
5+
'@clerk/upgrade': major
6+
'@clerk/nextjs': major
7+
'@clerk/shared': major
8+
'@clerk/react': major
9+
'@clerk/nuxt': major
10+
'@clerk/vue': major
11+
---
12+
13+
`getToken()` now throws `ClerkOfflineError` instead of returning `null` when the client is offline.
14+
15+
This makes it explicit that a token fetch failure was due to network conditions, not authentication state. Previously, returning `null` could be misinterpreted as "user is signed out," potentially causing the cached token to be cleared.
16+
17+
To handle this change, catch `ClerkOfflineError` from `getToken()` calls:
18+
19+
```typescript
20+
import { ClerkOfflineError } from '@clerk/react/errors';
21+
22+
try {
23+
const token = await session.getToken();
24+
} catch (error) {
25+
if (ClerkOfflineError.is(error)) {
26+
// Handle offline scenario - show offline UI, retry later, etc.
27+
}
28+
throw error;
29+
}
30+
```

‎packages/clerk-js/src/core/__tests__/clerk.test.ts‎

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { EmailLinkErrorCodeStatus } from '@clerk/shared/error';
1+
import { ClerkOfflineError, EmailLinkErrorCodeStatus } from '@clerk/shared/error';
22
import type {
33
ActiveSessionResource,
44
PendingSessionResource,
@@ -645,6 +645,73 @@ describe('Clerk singleton', () => {
645645
expect(sut.session).toMatchObject(mockSessionWithOrganization);
646646
});
647647
});
648+
649+
describe('when offline', () => {
650+
const mockSession = {
651+
id: '1',
652+
remove: vi.fn(),
653+
status: 'active',
654+
user: {},
655+
touch: vi.fn(() => Promise.resolve()),
656+
getToken: vi.fn(),
657+
lastActiveToken: { getRawString: () => 'mocked-token' },
658+
};
659+
let eventBusSpy: ReturnType<typeof vi.spyOn>;
660+
661+
beforeEach(() => {
662+
eventBusSpy = vi.spyOn(eventBus, 'emit');
663+
});
664+
665+
afterEach(() => {
666+
mockSession.remove.mockReset();
667+
mockSession.touch.mockReset();
668+
mockSession.getToken.mockReset();
669+
eventBusSpy?.mockRestore();
670+
});
671+
672+
it('does not emit TokenUpdate with null when getToken throws ClerkOfflineError', async () => {
673+
mockSession.touch.mockReturnValue(Promise.resolve());
674+
mockSession.getToken.mockRejectedValue(new ClerkOfflineError('Network request failed while offline.'));
675+
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] }));
676+
677+
const sut = new Clerk(productionPublishableKey);
678+
await sut.load();
679+
680+
await sut.setActive({ session: mockSession as any as ActiveSessionResource });
681+
682+
expect(eventBusSpy).toHaveBeenCalledWith('token:update', { token: mockSession.lastActiveToken });
683+
684+
const tokenUpdateCalls = eventBusSpy.mock.calls.filter(call => call[0] === 'token:update');
685+
const nullTokenCalls = tokenUpdateCalls.filter(call => call[1]?.token === null);
686+
expect(nullTokenCalls.length).toBe(0);
687+
});
688+
689+
it('preserves existing auth state when offline during setActive', async () => {
690+
mockSession.touch.mockReturnValue(Promise.resolve());
691+
mockSession.getToken.mockRejectedValue(new ClerkOfflineError('Network request failed while offline.'));
692+
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] }));
693+
694+
const sut = new Clerk(productionPublishableKey);
695+
await sut.load();
696+
697+
await sut.setActive({ session: mockSession as any as ActiveSessionResource });
698+
699+
expect(sut.session).toBeDefined();
700+
});
701+
702+
it('re-throws non-offline errors from getToken', async () => {
703+
mockSession.touch.mockReturnValue(Promise.resolve());
704+
mockSession.getToken.mockRejectedValue(new Error('Some other error'));
705+
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] }));
706+
707+
const sut = new Clerk(productionPublishableKey);
708+
await sut.load();
709+
710+
await expect(sut.setActive({ session: mockSession as any as ActiveSessionResource })).rejects.toThrow(
711+
'Some other error',
712+
);
713+
});
714+
});
648715
});
649716

650717
describe('.load()', () => {

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { inBrowser as inClientSide, isValidBrowserOnline } from '@clerk/shared/browser';
22
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
33
import {
4+
ClerkOfflineError,
45
ClerkRuntimeError,
56
EmailLinkError,
67
EmailLinkErrorCodeStatus,
@@ -1559,16 +1560,21 @@ export class Clerk implements ClerkInterface {
15591560
}
15601561

15611562
// getToken syncs __session and __client_uat to cookies using events.TokenUpdate dispatched event.
1562-
const token = await newSession?.getToken();
1563-
if (!token) {
1564-
if (!isValidBrowserOnline()) {
1563+
try {
1564+
const token = await newSession?.getToken();
1565+
if (!token) {
1566+
eventBus.emit(events.TokenUpdate, { token: null });
1567+
}
1568+
} catch (error) {
1569+
if (ClerkOfflineError.is(error)) {
15651570
debugLogger.warn(
1566-
'Token is null when setting active session (offline)',
1571+
'Token fetch failed when setting active session (offline). Preserving existing auth state.',
15671572
{ sessionId: newSession?.id },
15681573
'clerk',
15691574
);
1575+
} else {
1576+
throw error;
15701577
}
1571-
eventBus.emit(events.TokenUpdate, { token: null });
15721578
}
15731579

15741580
//2. When navigation is required we emit the session as undefined,

‎packages/clerk-js/src/core/resources/Session.ts‎

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createCheckAuthorization } from '@clerk/shared/authorization';
2-
import { ClerkWebAuthnError, is4xxError, MissingExpiredTokenError } from '@clerk/shared/error';
2+
import { isValidBrowserOnline } from '@clerk/shared/browser';
3+
import { ClerkOfflineError, ClerkWebAuthnError, is4xxError, MissingExpiredTokenError } from '@clerk/shared/error';
34
import {
45
convertJSONToPublicKeyRequestOptions,
56
serializePublicKeyCredentialAssertion,
@@ -109,17 +110,41 @@ export class Session extends BaseResource implements SessionResource {
109110

110111
getToken: GetToken = async (options?: GetTokenOptions): Promise<string | null> => {
111112
// This will retry the getToken call if it fails with a non-4xx error
112-
// We're going to trigger 8 retries in the span of ~3 minutes,
113-
// Example delays: 3s, 5s, 13s, 19s, 26s, 34s, 43s, 50s, total: ~193s
114-
return retry(() => this._getToken(options), {
115-
factor: 1.55,
116-
initialDelay: 3 * 1000,
117-
maxDelayBetweenRetries: 50 * 1_000,
118-
jitter: false,
119-
shouldRetry: (error, iterationsCount) => {
120-
return !is4xxError(error) && iterationsCount <= 8;
121-
},
122-
});
113+
// For offline state, we use shorter retries (~15s total) before throwing ClerkOfflineError
114+
// For other errors, we retry up to 8 times over ~3 minutes
115+
try {
116+
const result = await retry(() => this._getToken(options), {
117+
factor: 1.55,
118+
initialDelay: 3 * 1000,
119+
maxDelayBetweenRetries: 50 * 1_000,
120+
jitter: false,
121+
shouldRetry: (error, iterationsCount) => {
122+
if (is4xxError(error)) {
123+
return false;
124+
}
125+
126+
if (!isValidBrowserOnline()) {
127+
return iterationsCount <= 3;
128+
}
129+
return iterationsCount <= 8;
130+
},
131+
});
132+
133+
// If we're offline and got a null/empty result, this is likely due to
134+
// BaseResource._baseFetch returning null when offline (silent failure mode).
135+
// Throw ClerkOfflineError to make the offline state explicit.
136+
if (!result && !isValidBrowserOnline()) {
137+
throw new ClerkOfflineError('Network request failed while offline. The browser appears to be disconnected.');
138+
}
139+
140+
return result;
141+
} catch (error) {
142+
// If the browser is offline after retries, throw ClerkOfflineError
143+
if (!isValidBrowserOnline()) {
144+
throw new ClerkOfflineError('Network request failed while offline. The browser appears to be disconnected.');
145+
}
146+
throw error;
147+
}
123148
};
124149

125150
checkAuthorization: CheckAuthorization = params => {
@@ -399,12 +424,14 @@ export class Session extends BaseResource implements SessionResource {
399424
const params: Record<string, string | null> = template ? {} : { organizationId: organizationId ?? null };
400425
const lastActiveToken = this.lastActiveToken?.getRawString();
401426

402-
return Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => {
427+
const tokenResolver = Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => {
403428
if (MissingExpiredTokenError.is(e) && lastActiveToken) {
404429
return Token.create(path, { ...params }, { expired_token: lastActiveToken });
405430
}
406431
throw e;
407432
});
433+
434+
return tokenResolver;
408435
}
409436

410437
#dispatchTokenEvents(token: TokenResource, shouldDispatch: boolean): void {

‎packages/clerk-js/src/core/resources/__tests__/Session.test.ts‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ClerkAPIResponseError } from '@clerk/shared/error';
1+
import { ClerkAPIResponseError, ClerkOfflineError } from '@clerk/shared/error';
22
import type { InstanceType, OrganizationJSON, SessionJSON } from '@clerk/shared/types';
33
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
44

@@ -260,24 +260,24 @@ describe('Session', () => {
260260
});
261261

262262
describe('with offline browser and network failure', () => {
263-
let warnSpy;
264263
beforeEach(() => {
264+
// Use real timers for offline tests to avoid unhandled rejection issues with retry logic
265+
vi.useRealTimers();
265266
Object.defineProperty(window.navigator, 'onLine', {
266267
writable: true,
267268
value: false,
268269
});
269-
warnSpy = vi.spyOn(console, 'warn').mockReturnValue();
270270
});
271271

272272
afterEach(() => {
273273
Object.defineProperty(window.navigator, 'onLine', {
274274
writable: true,
275275
value: true,
276276
});
277-
warnSpy.mockRestore();
277+
vi.useFakeTimers();
278278
});
279279

280-
it('returns null', async () => {
280+
it('throws ClerkOfflineError when offline', async () => {
281281
const session = new Session({
282282
status: 'active',
283283
id: 'session_1',
@@ -292,11 +292,33 @@ describe('Session', () => {
292292
mockNetworkFailedFetch();
293293
BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any;
294294

295-
const token = await session.getToken();
295+
try {
296+
await session.getToken({ skipCache: true });
297+
expect.fail('Expected ClerkOfflineError to be thrown');
298+
} catch (error) {
299+
expect(ClerkOfflineError.is(error)).toBe(true);
300+
}
301+
});
302+
303+
it('throws ClerkOfflineError after fetch fails while offline', async () => {
304+
const session = new Session({
305+
status: 'active',
306+
id: 'session_1',
307+
object: 'session',
308+
user: createUser({}),
309+
last_active_organization_id: 'activeOrganization',
310+
actor: null,
311+
created_at: new Date().getTime(),
312+
updated_at: new Date().getTime(),
313+
} as SessionJSON);
314+
315+
mockNetworkFailedFetch();
316+
BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any;
317+
318+
await expect(session.getToken({ skipCache: true })).rejects.toThrow(ClerkOfflineError);
296319

320+
// Fetch should have been called at least once
297321
expect(global.fetch).toHaveBeenCalled();
298-
expect(dispatchSpy).toHaveBeenCalledTimes(1);
299-
expect(token).toEqual(null);
300322
});
301323
});
302324

‎packages/nextjs/src/errors.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ export {
88
EmailLinkErrorCodeStatus,
99
} from './client-boundary/hooks';
1010

11-
export { isClerkAPIResponseError } from '@clerk/react/errors';
11+
export { ClerkOfflineError, isClerkAPIResponseError } from '@clerk/react/errors';

‎packages/nuxt/src/runtime/errors.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export {
2+
ClerkOfflineError,
23
isClerkAPIResponseError,
34
isClerkRuntimeError,
45
isEmailLinkError,

‎packages/react-router/src/errors.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export {
2+
ClerkOfflineError,
23
isClerkAPIResponseError,
34
isEmailLinkError,
45
isKnownError,

‎packages/react/src/errors.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export {
2+
ClerkOfflineError,
23
isClerkAPIResponseError,
34
isClerkRuntimeError,
45
isEmailLinkError,

‎packages/shared/src/__tests__/error.spec.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22

33
import type { ErrorThrowerOptions } from '../error';
4-
import { buildErrorThrower, ClerkRuntimeError, isClerkRuntimeError } from '../error';
4+
import { buildErrorThrower, ClerkOfflineError, ClerkRuntimeError, isClerkRuntimeError } from '../error';
55

66
describe('ErrorThrower', () => {
77
const errorThrower = buildErrorThrower({ packageName: '@clerk/test-package' });
@@ -62,3 +62,45 @@ describe('ClerkRuntimeError', () => {
6262
expect(isClerkRuntimeError(clerkRuntimeError)).toEqual(true);
6363
});
6464
});
65+
66+
describe('ClerkOfflineError', () => {
67+
it('is an instance of ClerkRuntimeError', () => {
68+
const error = new ClerkOfflineError('Network request failed');
69+
expect(error).toBeInstanceOf(ClerkRuntimeError);
70+
expect(error.code).toBe('clerk_offline');
71+
});
72+
73+
describe('ClerkOfflineError.is() type guard', () => {
74+
it('returns true for ClerkOfflineError instances', () => {
75+
const error = new ClerkOfflineError('test');
76+
expect(ClerkOfflineError.is(error)).toBe(true);
77+
});
78+
79+
it('returns true for ClerkRuntimeError with clerk_offline code', () => {
80+
const error = new ClerkRuntimeError('test', { code: 'clerk_offline' });
81+
expect(ClerkOfflineError.is(error)).toBe(true);
82+
});
83+
84+
it('returns false for other ClerkRuntimeError instances', () => {
85+
const error = new ClerkRuntimeError('test', { code: 'other_code' });
86+
expect(ClerkOfflineError.is(error)).toBe(false);
87+
});
88+
89+
it('returns false for regular Error instances', () => {
90+
const error = new Error('test');
91+
expect(ClerkOfflineError.is(error)).toBe(false);
92+
});
93+
94+
it('returns false for null', () => {
95+
expect(ClerkOfflineError.is(null)).toBe(false);
96+
});
97+
98+
it('returns false for undefined', () => {
99+
expect(ClerkOfflineError.is(undefined)).toBe(false);
100+
});
101+
102+
it('returns false for non-error objects', () => {
103+
expect(ClerkOfflineError.is({ message: 'test' })).toBe(false);
104+
});
105+
});
106+
});

0 commit comments

Comments
 (0)