Skip to content
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
30 changes: 30 additions & 0 deletions .changeset/sparkly-aliens-see.md
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +2 to +10
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'm not sure if this should be a major change in all sdks 🤔

Copy link
Member

Choose a reason for hiding this comment

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

I don't think so, but it doesn't really matter in practice as all of them are going to get a major anyways

Copy link
Member Author

Choose a reason for hiding this comment

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

Should I leave it like this then?

---

`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;
}
```
69 changes: 68 additions & 1 deletion packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EmailLinkErrorCodeStatus } from '@clerk/shared/error';
import { ClerkOfflineError, EmailLinkErrorCodeStatus } from '@clerk/shared/error';
import type {
ActiveSessionResource,
PendingSessionResource,
Expand Down Expand Up @@ -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<typeof vi.spyOn>;

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()', () => {
Expand Down
16 changes: 11 additions & 5 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -1559,16 +1560,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,
Expand Down
53 changes: 40 additions & 13 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -109,17 +110,41 @@ export class Session extends BaseResource implements SessionResource {

getToken: GetToken = async (options?: GetTokenOptions): Promise<string | null> => {
// 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 => {
Expand Down Expand Up @@ -399,12 +424,14 @@ export class Session extends BaseResource implements SessionResource {
const params: Record<string, string | null> = 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 {
Expand Down
38 changes: 30 additions & 8 deletions packages/clerk-js/src/core/resources/__tests__/Session.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -260,24 +260,24 @@ 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(() => {
Object.defineProperty(window.navigator, 'onLine', {
writable: true,
value: true,
});
warnSpy.mockRestore();
vi.useFakeTimers();
});

it('returns null', async () => {
it('throws ClerkOfflineError when offline', async () => {
const session = new Session({
status: 'active',
id: 'session_1',
Expand All @@ -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', 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);
});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/nextjs/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ export {
EmailLinkErrorCodeStatus,
} from './client-boundary/hooks';

export { isClerkAPIResponseError } from '@clerk/react/errors';
export { ClerkOfflineError, isClerkAPIResponseError } from '@clerk/react/errors';
1 change: 1 addition & 0 deletions packages/nuxt/src/runtime/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export {
ClerkOfflineError,
isClerkAPIResponseError,
isClerkRuntimeError,
isEmailLinkError,
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export {
ClerkOfflineError,
isClerkAPIResponseError,
isEmailLinkError,
isKnownError,
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export {
ClerkOfflineError,
isClerkAPIResponseError,
isClerkRuntimeError,
isEmailLinkError,
Expand Down
44 changes: 43 additions & 1 deletion packages/shared/src/__tests__/error.spec.ts
Original file line number Diff line number Diff line change
@@ -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' });
Expand Down Expand Up @@ -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);
});
});
});
1 change: 1 addition & 0 deletions packages/shared/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Loading
Loading