From 618871acdf5257505526cbca9c13124109cffc95 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Mon, 23 Feb 2026 20:04:58 +0100 Subject: [PATCH 1/7] fix(clerk-js): Prevent session cookie removal during offline token refresh --- .changeset/three-ads-fold.md | 5 + .../clerk-js/src/core/resources/Session.ts | 38 +++++++- .../core/resources/__tests__/Session.test.ts | 96 ++++++++++++++++--- 3 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 .changeset/three-ads-fold.md diff --git a/.changeset/three-ads-fold.md b/.changeset/three-ads-fold.md new file mode 100644 index 00000000000..9161e7e7d66 --- /dev/null +++ b/.changeset/three-ads-fold.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix random sign-outs when the browser temporarily loses network connectivity. diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index f839def8c18..84175459f8a 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -1,6 +1,12 @@ import { createCheckAuthorization } from '@clerk/shared/authorization'; import { isValidBrowserOnline } from '@clerk/shared/browser'; -import { ClerkOfflineError, ClerkWebAuthnError, is4xxError, MissingExpiredTokenError } from '@clerk/shared/error'; +import { + ClerkOfflineError, + ClerkRuntimeError, + ClerkWebAuthnError, + is4xxError, + MissingExpiredTokenError, +} from '@clerk/shared/error'; import { convertJSONToPublicKeyRequestOptions, serializePublicKeyCredentialAssertion, @@ -438,18 +444,28 @@ export class Session extends BaseResource implements SessionResource { // Dispatch tokenUpdate only for __session tokens with the session's active organization ID, and not JWT templates const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; + let result: string | null; + if (cacheResult) { // Proactive refresh is handled by timers scheduled in the cache // Prefer synchronous read to avoid microtask overhead when token is already resolved const cachedToken = cacheResult.entry.resolvedToken ?? (await cacheResult.entry.tokenResolver); - if (shouldDispatchTokenUpdate) { + // Don't emit empty token update when offline — preserve session cookie + if (shouldDispatchTokenUpdate && (cachedToken.getRawString() || isValidBrowserOnline())) { eventBus.emit(events.TokenUpdate, { token: cachedToken }); } - // Return null when raw string is empty to indicate signed-out state - return cachedToken.getRawString() || null; + result = cachedToken.getRawString() || null; + } else { + result = await this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache); + } + + // Throw when offline and no token so retry() in getToken() can fire. + // Without this, _getToken returns null (success) and retry() never calls shouldRetry. + if (result === null && !isValidBrowserOnline()) { + throw new ClerkRuntimeError('Network request failed while offline', { code: 'network_error' }); } - return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache); + return result; } #createTokenResolver( @@ -477,6 +493,12 @@ export class Session extends BaseResource implements SessionResource { return; } + // Don't dispatch empty tokens when offline — this would cause AuthCookieService + // to remove the session cookie even though the user is still authenticated. + if (!token.getRawString() && !isValidBrowserOnline()) { + return; + } + eventBus.emit(events.TokenUpdate, { token }); if (token.jwt) { @@ -534,6 +556,12 @@ export class Session extends BaseResource implements SessionResource { // This allows concurrent calls to continue using the stale token tokenResolver .then(token => { + // Don't cache or dispatch empty tokens from offline failures — + // preserve the stale-but-valid token in cache instead. + if (!token.getRawString() && !isValidBrowserOnline()) { + return; + } + // Cache the resolved token for future calls // Re-register onRefresh to handle the next refresh cycle when this token approaches expiration SessionTokenCache.set({ 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 6926672c397..43264d8cd01 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -261,8 +261,6 @@ describe('Session', () => { describe('with offline browser and network failure', () => { 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, @@ -274,10 +272,9 @@ describe('Session', () => { writable: true, value: true, }); - vi.useFakeTimers(); }); - it('throws ClerkOfflineError when offline', async () => { + it('throws ClerkOfflineError after retries when offline', async () => { const session = new Session({ status: 'active', id: 'session_1', @@ -292,15 +289,15 @@ describe('Session', () => { mockNetworkFailedFetch(); BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; - try { - await session.getToken({ skipCache: true }); - expect.fail('Expected ClerkOfflineError to be thrown'); - } catch (error) { - expect(ClerkOfflineError.is(error)).toBe(true); - } + const errorPromise = session.getToken({ skipCache: true }).catch(e => e); + + await vi.advanceTimersByTimeAsync(60_000); + + const error = await errorPromise; + expect(ClerkOfflineError.is(error)).toBe(true); }); - it('throws ClerkOfflineError after fetch fails while offline', async () => { + it('retries 3 times before throwing when offline', async () => { const session = new Session({ status: 'active', id: 'session_1', @@ -315,10 +312,39 @@ describe('Session', () => { mockNetworkFailedFetch(); BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; - await expect(session.getToken({ skipCache: true })).rejects.toThrow(ClerkOfflineError); + const errorPromise = session.getToken({ skipCache: true }).catch(e => e); + + await vi.advanceTimersByTimeAsync(60_000); + + await errorPromise; - // Fetch should have been called at least once - expect(global.fetch).toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledTimes(4); + }); + + it('does not emit token:update with an empty token when offline', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + mockNetworkFailedFetch(); + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; + + const errorPromise = session.getToken({ skipCache: true }).catch(e => e); + await vi.advanceTimersByTimeAsync(60_000); + await errorPromise; + + const emptyTokenUpdates = dispatchSpy.mock.calls.filter( + (call: unknown[]) => + call[0] === 'token:update' && !(call[1] as { token: { getRawString(): string } })?.token?.getRawString(), + ); + expect(emptyTokenUpdates).toHaveLength(0); }); }); @@ -588,6 +614,48 @@ describe('Session', () => { expect(requestSpy).not.toHaveBeenCalled(); }); + it('does not emit token:update with an empty token when background refresh fires while offline', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + requestSpy.mockClear(); + dispatchSpy.mockClear(); + + // Go offline before the refresh timer fires + Object.defineProperty(window.navigator, 'onLine', { writable: true, value: false }); + mockNetworkFailedFetch(); + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; + + // Advance to trigger the refresh timer (~43s) and let the refresh complete + await vi.advanceTimersByTimeAsync(44 * 1000); + + const emptyTokenUpdates = dispatchSpy.mock.calls.filter( + (call: unknown[]) => + call[0] === 'token:update' && !(call[1] as { token: { getRawString(): string } })?.token?.getRawString(), + ); + expect(emptyTokenUpdates).toHaveLength(0); + + // Come back online and restore mock + Object.defineProperty(window.navigator, 'onLine', { writable: true, value: true }); + BaseResource.clerk = clerkMock(); + + const token = await session.getToken(); + expect(token).toEqual(mockJwt); + }); + it('does not make API call when token has plenty of time remaining', async () => { BaseResource.clerk = clerkMock(); const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; From be6256a654e4e539a0962f440a12fa7182844a2f Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Fri, 27 Feb 2026 20:08:08 +0100 Subject: [PATCH 2/7] fixup! fix(clerk-js): Prevent session cookie removal during offline token refresh --- .../clerk-js/src/core/resources/Session.ts | 13 +++++- .../core/resources/__tests__/Session.test.ts | 46 ++++++++++++++++++- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 84175459f8a..fe8d0c3a2cc 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -456,6 +456,9 @@ export class Session extends BaseResource implements SessionResource { } result = cachedToken.getRawString() || null; } else { + if (!isValidBrowserOnline()) { + throw new ClerkRuntimeError('Browser is offline, skipping token fetch', { code: 'network_error' }); + } result = await this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache); } @@ -524,9 +527,15 @@ export class Session extends BaseResource implements SessionResource { }); return tokenResolver.then(token => { + const rawString = token.getRawString(); + if (!rawString) { + // Token fetch returned an empty response — this happens when _baseFetch returns null + // due to a network error while offline. Throw so retry logic in getToken() can handle it, + // rather than silently returning null (which callers interpret as "signed out"). + throw new ClerkRuntimeError('Token fetch returned empty response', { code: 'network_error' }); + } this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); - // Return null when raw string is empty to indicate signed-out state - return token.getRawString() || null; + return rawString; }); } 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 43264d8cd01..ac91fe940be 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -297,7 +297,7 @@ describe('Session', () => { expect(ClerkOfflineError.is(error)).toBe(true); }); - it('retries 3 times before throwing when offline', async () => { + it('retries 3 times before throwing when offline without making network requests', async () => { const session = new Session({ status: 'active', id: 'session_1', @@ -312,13 +312,16 @@ describe('Session', () => { mockNetworkFailedFetch(); BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; + const getTokenSpy = vi.spyOn(session as any, '_getToken'); + const errorPromise = session.getToken({ skipCache: true }).catch(e => e); await vi.advanceTimersByTimeAsync(60_000); await errorPromise; - expect(global.fetch).toHaveBeenCalledTimes(4); + expect(getTokenSpy).toHaveBeenCalledTimes(4); + expect(global.fetch).toHaveBeenCalledTimes(0); }); it('does not emit token:update with an empty token when offline', async () => { @@ -346,6 +349,45 @@ describe('Session', () => { ); expect(emptyTokenUpdates).toHaveLength(0); }); + + it('throws error instead of returning null when browser recovers mid-request', async () => { + // Simulate the race condition: + // 1. _baseFetch catches a network error while offline → returns null + // 2. Browser comes back online before _getToken checks isValidBrowserOnline() + // 3. _getToken sees result=null but browser is online → skips the throw → returns null + // The caller gets null which looks like "signed out" even though user is authenticated. + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + // Browser was offline (set by parent describe's beforeEach) but has now recovered. + Object.defineProperty(window.navigator, 'onLine', { writable: true, value: true }); + + // Mock _fetch to return null, simulating what _baseFetch does when the offline + // branch fires. The browser was offline when the catch + // ran, but has since recovered by the time _getToken checks. + const fetchSpy = vi.spyOn(BaseResource, '_fetch' as any).mockResolvedValue(null); + + try { + const promise = session.getToken(); + // Suppress unhandled rejection from intermediate retry promises during timer advancement. + // The assertion below still checks the original rejected promise. + promise.catch(() => {}); + // Advance timers to allow all retries to complete + await vi.advanceTimersByTimeAsync(200_000); + // Should throw — not silently return null + await expect(promise).rejects.toThrow(); + } finally { + fetchSpy.mockRestore(); + } + }); }); it(`uses the current session's lastActiveOrganizationId by default, not clerk.organization.id`, async () => { From 4c86bb924d923e7daa0957673f94c0cfb57e9c4e Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Mon, 2 Mar 2026 15:25:52 +0100 Subject: [PATCH 3/7] fixup! fix(clerk-js): Prevent session cookie removal during offline token refresh --- .../clerk-js/src/core/resources/Session.ts | 17 +++++----- .../core/resources/__tests__/Session.test.ts | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index fe8d0c3a2cc..299395ac404 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -450,8 +450,9 @@ export class Session extends BaseResource implements SessionResource { // Proactive refresh is handled by timers scheduled in the cache // Prefer synchronous read to avoid microtask overhead when token is already resolved const cachedToken = cacheResult.entry.resolvedToken ?? (await cacheResult.entry.tokenResolver); - // Don't emit empty token update when offline — preserve session cookie - if (shouldDispatchTokenUpdate && (cachedToken.getRawString() || isValidBrowserOnline())) { + // Only emit token updates when we have an actual token — emitting with an empty + // token causes AuthCookieService to remove the __session cookie (looks like sign-out). + if (shouldDispatchTokenUpdate && cachedToken.getRawString()) { eventBus.emit(events.TokenUpdate, { token: cachedToken }); } result = cachedToken.getRawString() || null; @@ -496,9 +497,9 @@ export class Session extends BaseResource implements SessionResource { return; } - // Don't dispatch empty tokens when offline — this would cause AuthCookieService - // to remove the session cookie even though the user is still authenticated. - if (!token.getRawString() && !isValidBrowserOnline()) { + // Never dispatch empty tokens — this would cause AuthCookieService to remove + // the __session cookie even though the user is still authenticated. + if (!token.getRawString()) { return; } @@ -565,9 +566,9 @@ export class Session extends BaseResource implements SessionResource { // This allows concurrent calls to continue using the stale token tokenResolver .then(token => { - // Don't cache or dispatch empty tokens from offline failures — - // preserve the stale-but-valid token in cache instead. - if (!token.getRawString() && !isValidBrowserOnline()) { + // Never cache or dispatch empty tokens — preserve the stale-but-valid + // token in cache instead of replacing it with an empty one. + if (!token.getRawString()) { return; } 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 ac91fe940be..4ccae5510e2 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -390,6 +390,38 @@ describe('Session', () => { }); }); + it('does not emit token:update with an empty token even when online', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; + + const fetchSpy = vi.spyOn(BaseResource, '_fetch' as any).mockResolvedValue(null); + + try { + const promise = session.getToken(); + promise.catch(() => {}); + await vi.advanceTimersByTimeAsync(200_000); + await expect(promise).rejects.toThrow(); + + const emptyTokenUpdates = dispatchSpy.mock.calls.filter( + (call: unknown[]) => + call[0] === 'token:update' && !(call[1] as { token: { getRawString(): string } })?.token?.getRawString(), + ); + expect(emptyTokenUpdates).toHaveLength(0); + } finally { + fetchSpy.mockRestore(); + } + }); + it(`uses the current session's lastActiveOrganizationId by default, not clerk.organization.id`, async () => { BaseResource.clerk = clerkMock({ organization: new Organization({ id: 'oldActiveOrganization' } as OrganizationJSON), From 2c9a84e5dbe740ac7c8750a78967e12effc5bf94 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Mon, 2 Mar 2026 16:18:32 +0100 Subject: [PATCH 4/7] fixup! fix(clerk-js): Prevent session cookie removal during offline token refresh --- packages/clerk-js/bundlewatch.config.json | 2 +- packages/shared/src/__tests__/browser.spec.ts | 4 ++-- packages/shared/src/browser.ts | 13 ++++++------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 8cbc8199d3a..a268b1c02dd 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,6 +1,6 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "539KB" }, + { "path": "./dist/clerk.js", "maxSize": "540KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "66KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "108KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "307KB" }, diff --git a/packages/shared/src/__tests__/browser.spec.ts b/packages/shared/src/__tests__/browser.spec.ts index d370f886d4e..4cb73c6989a 100644 --- a/packages/shared/src/__tests__/browser.spec.ts +++ b/packages/shared/src/__tests__/browser.spec.ts @@ -162,7 +162,7 @@ describe('isValidBrowserOnline', () => { expect(isValidBrowserOnline()).toBe(false); }); - it('returns FALSE if connection is NOT online, navigator is online, has disabled the webdriver flag, and is not a bot', () => { + it('returns TRUE if connection reports zero values but navigator is online (headless browser)', () => { userAgentGetter.mockReturnValue( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/109.0', ); @@ -170,7 +170,7 @@ describe('isValidBrowserOnline', () => { onLineGetter.mockReturnValue(true); connectionGetter.mockReturnValue({ downlink: 0, rtt: 0 }); - expect(isValidBrowserOnline()).toBe(false); + expect(isValidBrowserOnline()).toBe(true); }); it('returns FALSE if connection is online, navigator is NOT online, has disabled the webdriver flag, and is not a bot', () => { diff --git a/packages/shared/src/browser.ts b/packages/shared/src/browser.ts index 8b27e783ed4..ea61931f6d3 100644 --- a/packages/shared/src/browser.ts +++ b/packages/shared/src/browser.ts @@ -73,13 +73,12 @@ export function isBrowserOnline(): boolean { return false; } - const isNavigatorOnline = navigator?.onLine; - - // Being extra safe with the experimental `connection` property, as it is not defined in all browsers - // https://developer.mozilla.org/en-US/docs/Web/API/Navigator/connection#browser_compatibility - // @ts-ignore - const isExperimentalConnectionOnline = navigator?.connection?.rtt !== 0 && navigator?.connection?.downlink !== 0; - return isExperimentalConnectionOnline && isNavigatorOnline; + // navigator.onLine is the standard API and is reliable for detecting + // complete disconnection (airplane mode, WiFi off, etc.). + // The experimental navigator.connection API (rtt/downlink) was previously + // used as a secondary signal, but it reports zero values in headless browsers + // and CI environments even when connected, causing false offline detection. + return !!navigator.onLine; } /** From 2c09e1eaeb90ee4fe3fefa400e2d071bfc850d78 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Mon, 2 Mar 2026 16:58:11 +0100 Subject: [PATCH 5/7] fixup! fix(clerk-js): Prevent session cookie removal during offline token refresh --- packages/clerk-js/src/core/resources/Session.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 299395ac404..684265fc3c2 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -1,5 +1,5 @@ import { createCheckAuthorization } from '@clerk/shared/authorization'; -import { isValidBrowserOnline } from '@clerk/shared/browser'; +import { isBrowserOnline, isValidBrowserOnline } from '@clerk/shared/browser'; import { ClerkOfflineError, ClerkRuntimeError, @@ -457,7 +457,7 @@ export class Session extends BaseResource implements SessionResource { } result = cachedToken.getRawString() || null; } else { - if (!isValidBrowserOnline()) { + if (!isBrowserOnline()) { throw new ClerkRuntimeError('Browser is offline, skipping token fetch', { code: 'network_error' }); } result = await this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache); From 58fbac983d9a1dcfc1b5ebfa17eb6d5784602537 Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 6 Mar 2026 14:03:16 -0600 Subject: [PATCH 6/7] refactor(clerk-js): Simplify _getToken control flow and trim comment Flatten nested if/else in _getToken to make the three branches (cache hit / offline / fetch) equal peers. Trim redundant comment in #fetchToken. Co-Authored-By: Claude Opus 4.6 --- packages/clerk-js/src/core/resources/Session.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 684265fc3c2..2d37bca89c2 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -456,10 +456,9 @@ export class Session extends BaseResource implements SessionResource { eventBus.emit(events.TokenUpdate, { token: cachedToken }); } result = cachedToken.getRawString() || null; + } else if (!isBrowserOnline()) { + throw new ClerkRuntimeError('Browser is offline, skipping token fetch', { code: 'network_error' }); } else { - if (!isBrowserOnline()) { - throw new ClerkRuntimeError('Browser is offline, skipping token fetch', { code: 'network_error' }); - } result = await this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache); } @@ -530,8 +529,7 @@ export class Session extends BaseResource implements SessionResource { return tokenResolver.then(token => { const rawString = token.getRawString(); if (!rawString) { - // Token fetch returned an empty response — this happens when _baseFetch returns null - // due to a network error while offline. Throw so retry logic in getToken() can handle it, + // Throw so retry logic in getToken() can handle it, // rather than silently returning null (which callers interpret as "signed out"). throw new ClerkRuntimeError('Token fetch returned empty response', { code: 'network_error' }); } From 16e98bfd7ff4bfaf285359d4697a1b26d5e1b8ee Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 6 Mar 2026 14:31:14 -0600 Subject: [PATCH 7/7] test: Add integration tests for offline session persistence Verify that users are not spuriously signed out when the browser temporarily loses network connectivity. Tests cover token endpoint outage recovery, page reload after outage, and full offline/online cycle via context.setOffline. Co-Authored-By: Claude Opus 4.6 --- .../tests/offline-session-persistence.test.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 integration/tests/offline-session-persistence.test.ts diff --git a/integration/tests/offline-session-persistence.test.ts b/integration/tests/offline-session-persistence.test.ts new file mode 100644 index 00000000000..7865a8982c1 --- /dev/null +++ b/integration/tests/offline-session-persistence.test.ts @@ -0,0 +1,124 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import type { FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })( + 'offline session persistence @generic', + ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('user remains signed in after token endpoint outage and recovery', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + await u.po.expect.toBeSignedIn(); + + const initialToken = await page.evaluate(() => window.Clerk?.session?.getToken()); + expect(initialToken).toBeTruthy(); + + // Simulate token endpoint outage — requests will fail with network error + await page.route('**/v1/client/sessions/*/tokens**', route => route.abort('failed')); + + // Clear token cache so any subsequent internal refresh hits the failing endpoint + await page.evaluate(() => window.Clerk?.session?.clearCache()); + + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(3_000); + + // Restore network + await page.unrouteAll(); + + // The session cookie must NOT have been removed during the outage. + // Before the fix, empty tokens would be dispatched to AuthCookieService, + // which interpreted them as sign-out and removed the __session cookie. + await u.po.expect.toBeSignedIn(); + + // Verify recovery: a fresh token can still be obtained + const recoveredToken = await page.evaluate(() => window.Clerk?.session?.getToken()); + expect(recoveredToken).toBeTruthy(); + }); + + test('session survives page reload after token endpoint outage', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + await u.po.expect.toBeSignedIn(); + + // Fail all token refresh requests + await page.route('**/v1/client/sessions/*/tokens**', route => route.abort('failed')); + + // Force a refresh attempt that will fail + await page.evaluate(() => window.Clerk?.session?.clearCache()); + + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(2_000); + + // Restore network before reload + await page.unrouteAll(); + + // Reload the page — if the __session cookie was removed during the outage, + // the server would treat this as an unauthenticated request + await page.reload(); + await u.po.clerk.toBeLoaded(); + + await u.po.expect.toBeSignedIn(); + }); + + test('session cookie persists when browser goes fully offline and recovers', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + await u.po.expect.toBeSignedIn(); + + // Go fully offline — sets navigator.onLine to false, + // which triggers the isBrowserOnline() guard in _getToken + await context.setOffline(true); + + // Clear token cache while offline + await page.evaluate(() => window.Clerk?.session?.clearCache()); + + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(2_000); + + // Come back online + await context.setOffline(false); + + // Reload — session cookie must still be intact + await page.reload(); + await u.po.clerk.toBeLoaded(); + + await u.po.expect.toBeSignedIn(); + + // Confirm a fresh token can be obtained after recovery + const token = await page.evaluate(() => window.Clerk?.session?.getToken()); + expect(token).toBeTruthy(); + }); + }, +);