From 17c819a1f64c6a60b3941e27f07e89f94f4de137 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Wed, 28 Jan 2026 22:13:48 +0100 Subject: [PATCH 1/3] feat(clerk-js): Throw ClerkOfflineError from getToken() when offline BREAKING CHANGE: `getToken()` now throws `ClerkOfflineError` instead of returning `null` when the client is offline. This makes it explicit that the failure was due to network conditions, not authentication state. --- .../clerk-js/src/core/__tests__/clerk.test.ts | 69 ++++++++++++++- packages/clerk-js/src/core/clerk.ts | 16 ++-- .../clerk-js/src/core/resources/Session.ts | 53 +++++++++--- .../core/resources/__tests__/Session.test.ts | 38 +++++++-- packages/nextjs/src/errors.ts | 2 +- packages/nuxt/src/runtime/errors.ts | 1 + packages/react-router/src/errors.ts | 1 + packages/react/src/errors.ts | 1 + packages/shared/src/__tests__/error.spec.ts | 44 +++++++++- packages/shared/src/error.ts | 1 + .../shared/src/errors/clerkOfflineError.ts | 54 ++++++++++++ packages/shared/src/getToken.ts | 3 + .../__snapshots__/exports.test.ts.snap | 1 + packages/tanstack-react-start/src/errors.ts | 1 + .../core-3/changes/gettoken-offline-error.md | 84 +++++++++++++++++++ packages/vue/src/errors.ts | 1 + 16 files changed, 341 insertions(+), 29 deletions(-) create mode 100644 packages/shared/src/errors/clerkOfflineError.ts create mode 100644 packages/upgrade/src/versions/core-3/changes/gettoken-offline-error.md diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index f46c3a4686e..f172b12fbcb 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -1,4 +1,4 @@ -import { EmailLinkErrorCodeStatus } from '@clerk/shared/error'; +import { ClerkOfflineError, EmailLinkErrorCodeStatus } from '@clerk/shared/error'; import type { ActiveSessionResource, PendingSessionResource, @@ -645,6 +645,73 @@ describe('Clerk singleton', () => { expect(sut.session).toMatchObject(mockSessionWithOrganization); }); }); + + describe('when offline', () => { + const mockSession = { + id: '1', + remove: vi.fn(), + status: 'active', + user: {}, + touch: vi.fn(() => Promise.resolve()), + getToken: vi.fn(), + lastActiveToken: { getRawString: () => 'mocked-token' }, + }; + let eventBusSpy: ReturnType; + + beforeEach(() => { + eventBusSpy = vi.spyOn(eventBus, 'emit'); + }); + + afterEach(() => { + mockSession.remove.mockReset(); + mockSession.touch.mockReset(); + mockSession.getToken.mockReset(); + eventBusSpy?.mockRestore(); + }); + + it('does not emit TokenUpdate with null when getToken throws ClerkOfflineError', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockSession.getToken.mockRejectedValue(new ClerkOfflineError('Network request failed while offline.')); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + + expect(eventBusSpy).toHaveBeenCalledWith('token:update', { token: mockSession.lastActiveToken }); + + const tokenUpdateCalls = eventBusSpy.mock.calls.filter(call => call[0] === 'token:update'); + const nullTokenCalls = tokenUpdateCalls.filter(call => call[1]?.token === null); + expect(nullTokenCalls.length).toBe(0); + }); + + it('preserves existing auth state when offline during setActive', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockSession.getToken.mockRejectedValue(new ClerkOfflineError('Network request failed while offline.')); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + await sut.setActive({ session: mockSession as any as ActiveSessionResource }); + + expect(sut.session).toBeDefined(); + }); + + it('re-throws non-offline errors from getToken', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockSession.getToken.mockRejectedValue(new Error('Some other error')); + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] })); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + await expect(sut.setActive({ session: mockSession as any as ActiveSessionResource })).rejects.toThrow( + 'Some other error', + ); + }); + }); }); describe('.load()', () => { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 52ad8d2a7df..bcb940ae9bb 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1,6 +1,7 @@ import { inBrowser as inClientSide, isValidBrowserOnline } from '@clerk/shared/browser'; import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus'; import { + ClerkOfflineError, ClerkRuntimeError, EmailLinkError, EmailLinkErrorCodeStatus, @@ -1536,16 +1537,21 @@ export class Clerk implements ClerkInterface { } // getToken syncs __session and __client_uat to cookies using events.TokenUpdate dispatched event. - const token = await newSession?.getToken(); - if (!token) { - if (!isValidBrowserOnline()) { + try { + const token = await newSession?.getToken(); + if (!token) { + eventBus.emit(events.TokenUpdate, { token: null }); + } + } catch (error) { + if (ClerkOfflineError.is(error)) { debugLogger.warn( - 'Token is null when setting active session (offline)', + 'Token fetch failed when setting active session (offline). Preserving existing auth state.', { sessionId: newSession?.id }, 'clerk', ); + } else { + throw error; } - eventBus.emit(events.TokenUpdate, { token: null }); } //2. When navigation is required we emit the session as undefined, diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 842230da8f5..5facf261e91 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -1,5 +1,6 @@ import { createCheckAuthorization } from '@clerk/shared/authorization'; -import { ClerkWebAuthnError, is4xxError, MissingExpiredTokenError } from '@clerk/shared/error'; +import { isValidBrowserOnline } from '@clerk/shared/browser'; +import { ClerkOfflineError, ClerkWebAuthnError, is4xxError, MissingExpiredTokenError } from '@clerk/shared/error'; import { convertJSONToPublicKeyRequestOptions, serializePublicKeyCredentialAssertion, @@ -109,17 +110,41 @@ export class Session extends BaseResource implements SessionResource { getToken: GetToken = async (options?: GetTokenOptions): Promise => { // 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 - return retry(() => this._getToken(options), { - factor: 1.55, - initialDelay: 3 * 1000, - maxDelayBetweenRetries: 50 * 1_000, - jitter: false, - shouldRetry: (error, iterationsCount) => { - return !is4xxError(error) && iterationsCount <= 8; - }, - }); + // For offline state, we use shorter retries (~15s total) before throwing ClerkOfflineError + // For other errors, we retry up to 8 times over ~3 minutes + try { + const result = await retry(() => this._getToken(options), { + factor: 1.55, + initialDelay: 3 * 1000, + maxDelayBetweenRetries: 50 * 1_000, + jitter: false, + shouldRetry: (error, iterationsCount) => { + if (is4xxError(error)) { + return false; + } + + if (!isValidBrowserOnline()) { + return iterationsCount <= 3; + } + return iterationsCount <= 8; + }, + }); + + // If we're offline and got a null/empty result, this is likely due to + // BaseResource._baseFetch returning null when offline (silent failure mode). + // Throw ClerkOfflineError to make the offline state explicit. + if (!result && !isValidBrowserOnline()) { + throw new ClerkOfflineError('Network request failed while offline. The browser appears to be disconnected.'); + } + + return result; + } catch (error) { + // If the browser is offline after retries, throw ClerkOfflineError + if (!isValidBrowserOnline()) { + throw new ClerkOfflineError('Network request failed while offline. The browser appears to be disconnected.'); + } + throw error; + } }; checkAuthorization: CheckAuthorization = params => { @@ -399,12 +424,14 @@ export class Session extends BaseResource implements SessionResource { const params: Record = template ? {} : { organizationId: organizationId ?? null }; const lastActiveToken = this.lastActiveToken?.getRawString(); - return Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => { + const tokenResolver = Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => { if (MissingExpiredTokenError.is(e) && lastActiveToken) { return Token.create(path, { ...params }, { expired_token: lastActiveToken }); } throw e; }); + + return tokenResolver; } #dispatchTokenEvents(token: TokenResource, shouldDispatch: boolean): void { diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 28f904abe53..1a8bcaf1b53 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -1,4 +1,4 @@ -import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { ClerkAPIResponseError, ClerkOfflineError } from '@clerk/shared/error'; import type { InstanceType, OrganizationJSON, SessionJSON } from '@clerk/shared/types'; import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; @@ -260,13 +260,13 @@ describe('Session', () => { }); describe('with offline browser and network failure', () => { - let warnSpy; beforeEach(() => { + // Use real timers for offline tests to avoid unhandled rejection issues with retry logic + vi.useRealTimers(); Object.defineProperty(window.navigator, 'onLine', { writable: true, value: false, }); - warnSpy = vi.spyOn(console, 'warn').mockReturnValue(); }); afterEach(() => { @@ -274,10 +274,10 @@ describe('Session', () => { writable: true, value: true, }); - warnSpy.mockRestore(); + vi.useFakeTimers(); }); - it('returns null', async () => { + it('throws ClerkOfflineError when offline', { timeout: 20000 }, async () => { const session = new Session({ status: 'active', id: 'session_1', @@ -292,11 +292,33 @@ describe('Session', () => { mockNetworkFailedFetch(); BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; - const token = await session.getToken(); + try { + await session.getToken({ skipCache: true }); + expect.fail('Expected ClerkOfflineError to be thrown'); + } catch (error) { + expect(ClerkOfflineError.is(error)).toBe(true); + } + }); + + it('throws ClerkOfflineError after fetch fails while offline', { timeout: 20000 }, async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: 'activeOrganization', + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + mockNetworkFailedFetch(); + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; + + await expect(session.getToken({ skipCache: true })).rejects.toThrow(ClerkOfflineError); + // Fetch should have been called at least once expect(global.fetch).toHaveBeenCalled(); - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(token).toEqual(null); }); }); diff --git a/packages/nextjs/src/errors.ts b/packages/nextjs/src/errors.ts index f263b29c26e..79a5f72553e 100644 --- a/packages/nextjs/src/errors.ts +++ b/packages/nextjs/src/errors.ts @@ -8,4 +8,4 @@ export { EmailLinkErrorCodeStatus, } from './client-boundary/hooks'; -export { isClerkAPIResponseError } from '@clerk/react/errors'; +export { ClerkOfflineError, isClerkAPIResponseError } from '@clerk/react/errors'; diff --git a/packages/nuxt/src/runtime/errors.ts b/packages/nuxt/src/runtime/errors.ts index 98366fc26b4..f34178321c4 100644 --- a/packages/nuxt/src/runtime/errors.ts +++ b/packages/nuxt/src/runtime/errors.ts @@ -1,4 +1,5 @@ export { + ClerkOfflineError, isClerkAPIResponseError, isClerkRuntimeError, isEmailLinkError, diff --git a/packages/react-router/src/errors.ts b/packages/react-router/src/errors.ts index ab2d95ccdcc..6381944d22c 100644 --- a/packages/react-router/src/errors.ts +++ b/packages/react-router/src/errors.ts @@ -1,4 +1,5 @@ export { + ClerkOfflineError, isClerkAPIResponseError, isEmailLinkError, isKnownError, diff --git a/packages/react/src/errors.ts b/packages/react/src/errors.ts index 1528fd83607..5939724894a 100644 --- a/packages/react/src/errors.ts +++ b/packages/react/src/errors.ts @@ -1,4 +1,5 @@ export { + ClerkOfflineError, isClerkAPIResponseError, isClerkRuntimeError, isEmailLinkError, diff --git a/packages/shared/src/__tests__/error.spec.ts b/packages/shared/src/__tests__/error.spec.ts index d04a312977a..98fe6a85324 100644 --- a/packages/shared/src/__tests__/error.spec.ts +++ b/packages/shared/src/__tests__/error.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { ErrorThrowerOptions } from '../error'; -import { buildErrorThrower, ClerkRuntimeError, isClerkRuntimeError } from '../error'; +import { buildErrorThrower, ClerkOfflineError, ClerkRuntimeError, isClerkRuntimeError } from '../error'; describe('ErrorThrower', () => { const errorThrower = buildErrorThrower({ packageName: '@clerk/test-package' }); @@ -62,3 +62,45 @@ describe('ClerkRuntimeError', () => { expect(isClerkRuntimeError(clerkRuntimeError)).toEqual(true); }); }); + +describe('ClerkOfflineError', () => { + it('is an instance of ClerkRuntimeError', () => { + const error = new ClerkOfflineError('Network request failed'); + expect(error).toBeInstanceOf(ClerkRuntimeError); + expect(error.code).toBe('clerk_offline'); + }); + + describe('ClerkOfflineError.is() type guard', () => { + it('returns true for ClerkOfflineError instances', () => { + const error = new ClerkOfflineError('test'); + expect(ClerkOfflineError.is(error)).toBe(true); + }); + + it('returns true for ClerkRuntimeError with clerk_offline code', () => { + const error = new ClerkRuntimeError('test', { code: 'clerk_offline' }); + expect(ClerkOfflineError.is(error)).toBe(true); + }); + + it('returns false for other ClerkRuntimeError instances', () => { + const error = new ClerkRuntimeError('test', { code: 'other_code' }); + expect(ClerkOfflineError.is(error)).toBe(false); + }); + + it('returns false for regular Error instances', () => { + const error = new Error('test'); + expect(ClerkOfflineError.is(error)).toBe(false); + }); + + it('returns false for null', () => { + expect(ClerkOfflineError.is(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(ClerkOfflineError.is(undefined)).toBe(false); + }); + + it('returns false for non-error objects', () => { + expect(ClerkOfflineError.is({ message: 'test' })).toBe(false); + }); + }); +}); diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index 328a363015e..b75e569a5db 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -4,6 +4,7 @@ export { ClerkAPIError, isClerkAPIError } from './errors/clerkApiError'; export { ClerkAPIResponseError, isClerkAPIResponseError } from './errors/clerkApiResponseError'; export { ClerkError, isClerkError } from './errors/clerkError'; export { MissingExpiredTokenError } from './errors/missingExpiredTokenError'; +export { ClerkOfflineError } from './errors/clerkOfflineError'; export { buildErrorThrower, type ErrorThrower, type ErrorThrowerOptions } from './errors/errorThrower'; diff --git a/packages/shared/src/errors/clerkOfflineError.ts b/packages/shared/src/errors/clerkOfflineError.ts new file mode 100644 index 00000000000..d7d90842621 --- /dev/null +++ b/packages/shared/src/errors/clerkOfflineError.ts @@ -0,0 +1,54 @@ +import { ClerkRuntimeError, isClerkRuntimeError } from './clerkRuntimeError'; + +/** + * Error thrown when a network request fails due to the client being offline. + * + * This error is thrown instead of returning `null` to make it explicit that + * the failure was due to network conditions, not authentication state. + * + * @example + * ```typescript + * try { + * const token = await session.getToken(); + * } catch (error) { + * if (ClerkOfflineError.is(error)) { + * // Handle offline scenario + * showOfflineScreen(); + * } + * } + * ``` + */ +export class ClerkOfflineError extends ClerkRuntimeError { + static kind = 'ClerkOfflineError'; + static readonly ERROR_CODE = 'clerk_offline' as const; + + constructor(message: string) { + super(message, { code: ClerkOfflineError.ERROR_CODE }); + Object.setPrototypeOf(this, ClerkOfflineError.prototype); + } + + /** + * Type guard to check if an error is a ClerkOfflineError. + * This checks both instanceof and the error code to support cross-bundle/cross-realm errors + * + * @example + * ```typescript + * try { + * const token = await session.getToken(); + * } catch (error) { + * if (ClerkOfflineError.is(error)) { + * // error is typed as ClerkOfflineError + * console.log('User is offline'); + * } + * } + * ``` + */ + static is(error: unknown): error is ClerkOfflineError { + if (error === null || error === undefined) { + return false; + } + return ( + error instanceof ClerkOfflineError || (isClerkRuntimeError(error) && error.code === ClerkOfflineError.ERROR_CODE) + ); + } +} diff --git a/packages/shared/src/getToken.ts b/packages/shared/src/getToken.ts index a9b77255990..af3f3b465c3 100644 --- a/packages/shared/src/getToken.ts +++ b/packages/shared/src/getToken.ts @@ -106,6 +106,9 @@ async function waitForClerk(): Promise { * * @throws {ClerkRuntimeError} When Clerk fails to load within timeout (code: `clerk_runtime_load_timeout`) * + * @throws {ClerkOfflineError} When the browser is offline and unable to fetch a token (code: `clerk_offline`). + * Use `ClerkOfflineError.is(error)` to check for this error type. + * * @example * ```typescript * // In an Axios interceptor diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 23fd7c6c905..76c73c2f64e 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -2,6 +2,7 @@ exports[`errors public exports > should not change unexpectedly 1`] = ` [ + "ClerkOfflineError", "EmailLinkErrorCode", "EmailLinkErrorCodeStatus", "isClerkAPIResponseError", diff --git a/packages/tanstack-react-start/src/errors.ts b/packages/tanstack-react-start/src/errors.ts index ab2d95ccdcc..6381944d22c 100644 --- a/packages/tanstack-react-start/src/errors.ts +++ b/packages/tanstack-react-start/src/errors.ts @@ -1,4 +1,5 @@ export { + ClerkOfflineError, isClerkAPIResponseError, isEmailLinkError, isKnownError, diff --git a/packages/upgrade/src/versions/core-3/changes/gettoken-offline-error.md b/packages/upgrade/src/versions/core-3/changes/gettoken-offline-error.md new file mode 100644 index 00000000000..2583975a461 --- /dev/null +++ b/packages/upgrade/src/versions/core-3/changes/gettoken-offline-error.md @@ -0,0 +1,84 @@ +--- +title: '`getToken()` throws `ClerkOfflineError` when offline' +matcher: 'getToken\(|session\.getToken' +matcherFlags: 'm' +category: 'breaking' +--- + +`getToken()` now throws a `ClerkOfflineError` instead of returning `null` when the browser is offline. This change makes it explicit that the request failed due to network conditions, not because the user is signed out. + +### Before (Core 2) + +```typescript +const token = await session.getToken(); +if (token === null) { + // Ambiguous: could mean signed out OR offline +} +``` + +### After (Core 3) + +```typescript +import { ClerkOfflineError } from '@clerk/react/errors'; +// Or from other packages: '@clerk/nextjs/errors', '@clerk/vue/errors', etc. + +try { + const token = await session.getToken(); + // token is guaranteed to be a valid string if user is signed in +} catch (error) { + if (ClerkOfflineError.is(error)) { + // Handle offline scenario explicitly + showOfflineScreen(); + } else { + // Handle other errors + throw error; + } +} +``` + +### Using the Type Guard + +Use `ClerkOfflineError.is()` to check if an error is an offline error: + +```typescript +import { ClerkOfflineError } from '@clerk/react/errors'; + +try { + const token = await clerk.session?.getToken(); +} catch (error) { + if (ClerkOfflineError.is(error)) { + // TypeScript knows error is ClerkOfflineError here + console.log('User is offline'); + } +} +``` + +### Common Patterns + +**Pattern 1: Show offline UI** + +```typescript +catch (error) { + if (ClerkOfflineError.is(error)) { + showToast('You appear to be offline. Some features may be limited.'); + return; + } +} +``` + +**Pattern 2: Queue for retry** + +```typescript +catch (error) { + if (ClerkOfflineError.is(error)) { + retryQueue.add(() => fetchData()); + return; + } +} +``` + +### Migration Notes + +- If you were checking for `null` from `getToken()` to detect offline state, wrap the call in a try-catch and check for `ClerkOfflineError` +- The error is thrown after a short retry period (~15 seconds) to handle temporary network issues +- `getToken()` still returns `null` when the user is not signed in diff --git a/packages/vue/src/errors.ts b/packages/vue/src/errors.ts index ca64aeb9fbe..1b9934e3769 100644 --- a/packages/vue/src/errors.ts +++ b/packages/vue/src/errors.ts @@ -1,4 +1,5 @@ export { + ClerkOfflineError, isClerkAPIResponseError, isClerkRuntimeError, isEmailLinkError, From a78a03570131df5f619540220a99325b076e570b Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Wed, 28 Jan 2026 22:24:28 +0100 Subject: [PATCH 2/3] chore: add changeset --- .changeset/sparkly-aliens-see.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .changeset/sparkly-aliens-see.md diff --git a/.changeset/sparkly-aliens-see.md b/.changeset/sparkly-aliens-see.md new file mode 100644 index 00000000000..ee5ec4c3ee7 --- /dev/null +++ b/.changeset/sparkly-aliens-see.md @@ -0,0 +1,30 @@ +--- +'@clerk/tanstack-react-start': major +'@clerk/react-router': major +'@clerk/clerk-js': major +'@clerk/upgrade': major +'@clerk/nextjs': major +'@clerk/shared': major +'@clerk/react': major +'@clerk/nuxt': major +'@clerk/vue': major +--- + +`getToken()` now throws `ClerkOfflineError` instead of returning `null` when the client is offline. + +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. + +To handle this change, catch `ClerkOfflineError` from `getToken()` calls: + +```typescript +import { ClerkOfflineError } from '@clerk/react/errors'; + +try { + const token = await session.getToken(); +} catch (error) { + if (ClerkOfflineError.is(error)) { + // Handle offline scenario - show offline UI, retry later, etc. + } + throw error; +} +``` From a36112f149e6a21a49045c78d6ae7180ddc9b1ad Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Mon, 2 Feb 2026 22:12:51 +0100 Subject: [PATCH 3/3] fixup! feat(clerk-js): Throw ClerkOfflineError from getToken() when offline --- .../clerk-js/src/core/resources/__tests__/Session.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 1a8bcaf1b53..eecf9a4fb40 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -277,7 +277,7 @@ describe('Session', () => { vi.useFakeTimers(); }); - it('throws ClerkOfflineError when offline', { timeout: 20000 }, async () => { + it('throws ClerkOfflineError when offline', async () => { const session = new Session({ status: 'active', id: 'session_1', @@ -300,7 +300,7 @@ describe('Session', () => { } }); - it('throws ClerkOfflineError after fetch fails while offline', { timeout: 20000 }, async () => { + it('throws ClerkOfflineError after fetch fails while offline', async () => { const session = new Session({ status: 'active', id: 'session_1',