From d5ea434f9e27796d48d5c852dc9bc3f1fd7419a7 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 1 Jul 2026 18:57:12 +0300 Subject: [PATCH 1/2] fix(clerk-js): fail fast when the origin is slow at load When FAPI is slow or down, a cold clerk-js load hung for minutes: the /client fetch had no request timeout, and the load-failure recovery awaited getToken({ skipCache: true }), which runs a ~162s retry budget before Clerk marks itself loaded. During an outage the app sat unresponsive. Bound the standard-browser load with a 5s timeout in two places: around the /client fetch, and around the recovery mint. On a slow or failed /client, recovery renders identity from the __session cookie, clears the session's token cache via the existing clearCache() so the mint bypasses the cache (no skipCache, so no force_origin), and awaits a fresh mint under the timeout, keeping the cookie identity if it times out. The on-timeout clear stops the poller from awaiting an abandoned in-flight mint. Adds a timeLimit racer to @clerk/shared/utils. --- .changeset/clerkjs-fail-fast-slow-origin.md | 6 + .../clerk-js/src/core/__tests__/clerk.test.ts | 138 ++++++++++++++++++ packages/clerk-js/src/core/clerk.ts | 31 ++-- .../src/utils/__tests__/timeLimit.test.ts | 44 ++++++ packages/shared/src/utils/index.ts | 1 + packages/shared/src/utils/timeLimit.ts | 14 ++ 6 files changed, 222 insertions(+), 12 deletions(-) create mode 100644 .changeset/clerkjs-fail-fast-slow-origin.md create mode 100644 packages/shared/src/utils/__tests__/timeLimit.test.ts create mode 100644 packages/shared/src/utils/timeLimit.ts diff --git a/.changeset/clerkjs-fail-fast-slow-origin.md b/.changeset/clerkjs-fail-fast-slow-origin.md new file mode 100644 index 00000000000..960a703efc3 --- /dev/null +++ b/.changeset/clerkjs-fail-fast-slow-origin.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +--- + +Fail fast when the Clerk Frontend API (FAPI) is slow or unreachable during load. The client request and the load-recovery token mint are now bounded by a timeout, so a cold `Clerk.load()` renders identity from the session cookie in seconds instead of hanging while retries run. Adds a `timeLimit` utility to `@clerk/shared/utils`. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 521f9c4b55e..7f27a49c9bb 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -26,6 +26,9 @@ const mockEnvironmentFetch = vi.fn(() => Promise.resolve({})); vi.mock('../resources/Client'); vi.mock('../resources/Environment'); +const { mockCreateClientFromJwt } = vi.hoisted(() => ({ mockCreateClientFromJwt: vi.fn() })); +vi.mock('../jwt-client', () => ({ createClientFromJwt: mockCreateClientFromJwt })); + vi.mock('../auth/devBrowser', () => ({ createDevBrowser: (): DevBrowser => ({ clear: vi.fn(), @@ -818,6 +821,141 @@ describe('Clerk singleton', () => { }); }, ); + + describe('when the client fetch fails or hangs at load', () => { + let startPollSpy: ReturnType; + let stopPollSpy: ReturnType; + let callLog: string[]; + + const sessionClient = (session: any) => ({ + signedInSessions: [session], + lastActiveSessionId: session.id, + }); + const sessionlessClient = () => ({ signedInSessions: [], lastActiveSessionId: null }); + + // `load()` awaits native crypto (cookie suffix) before scheduling the timeout, so a single + // advance can run before the timer exists; advance the budget repeatedly until load settles. + const pumpUntilSettled = async (promise: Promise) => { + let settled = false; + const tracked = promise.then( + v => ((settled = true), v), + e => ((settled = true), Promise.reject(e)), + ); + for (let i = 0; i < 20 && !settled; i++) { + await vi.advanceTimersByTimeAsync(5000); + } + await tracked; + }; + + beforeEach(async () => { + callLog = []; + // Import at runtime (not top-level) so this module does not reorder the + // static graph and break the auto-mocked Environment/Client resources. + const { AuthCookieService } = await import('../auth/AuthCookieService'); + startPollSpy = vi + .spyOn(AuthCookieService.prototype, 'startPollingForToken') + .mockImplementation(() => void callLog.push('startPoll')); + stopPollSpy = vi + .spyOn(AuthCookieService.prototype, 'stopPollingForToken') + .mockImplementation(() => void callLog.push('stopPoll')); + mockCreateClientFromJwt.mockReturnValue(sessionlessClient()); + }); + + afterEach(() => { + startPollSpy.mockRestore(); + stopPollSpy.mockRestore(); + mockCreateClientFromJwt.mockReset(); + vi.useRealTimers(); + }); + + it('fails fast and marks Clerk degraded when the client fetch hangs', async () => { + vi.useFakeTimers(); + mockClientFetch.mockReturnValue(new Promise(() => {})); + + const sut = new Clerk(productionPublishableKey); + await pumpUntilSettled(sut.load()); + + expect(sut.status).toBe('degraded'); + expect(stopPollSpy).toHaveBeenCalled(); + expect(startPollSpy).toHaveBeenCalled(); + }); + + it('clears the token cache and mints without skipCache when the client fetch fails with a session present', async () => { + const getToken = vi.fn(() => { + callLog.push('getToken'); + return Promise.resolve('fresh-token'); + }); + const clearCache = vi.fn(() => void callLog.push('clearCache')); + const session = { + id: 'sess_1', + status: 'active', + user: {}, + getToken, + clearCache, + }; + mockClientFetch.mockRejectedValue(new Error('client fetch failed')); + mockCreateClientFromJwt.mockReturnValue(sessionClient(session)); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + expect(clearCache).toHaveBeenCalledTimes(1); + expect(getToken).toHaveBeenCalledTimes(1); + expect(getToken).toHaveBeenCalledWith(); + expect(getToken).not.toHaveBeenCalledWith({ skipCache: true }); + expect(callLog).toEqual(['startPoll', 'stopPoll', 'clearCache', 'getToken', 'startPoll']); + expect(sut.status).toBe('degraded'); + }); + + it('re-clears the token cache before restarting the poller when the recovery mint hangs', async () => { + vi.useFakeTimers(); + const getToken = vi.fn(() => { + callLog.push('getToken'); + return new Promise(() => {}); + }); + const clearCache = vi.fn(() => void callLog.push('clearCache')); + const session = { + id: 'sess_1', + status: 'active', + user: {}, + getToken, + clearCache, + }; + mockClientFetch.mockRejectedValue(new Error('client fetch failed')); + mockCreateClientFromJwt.mockReturnValue(sessionClient(session)); + + const sut = new Clerk(productionPublishableKey); + await pumpUntilSettled(sut.load()); + + expect(clearCache).toHaveBeenCalledTimes(2); + expect(callLog).toEqual(['startPoll', 'stopPoll', 'clearCache', 'getToken', 'clearCache', 'startPoll']); + const secondClearOrder = clearCache.mock.invocationCallOrder[1]; + const lastStartPollOrder = startPollSpy.mock.invocationCallOrder.at(-1); + expect(secondClearOrder).toBeLessThan(lastStartPollOrder!); + expect(sut.status).toBe('degraded'); + }); + + it('renders the empty client without minting when there is no session cookie', async () => { + mockClientFetch.mockRejectedValue(new Error('client fetch failed')); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + expect(sut.session).toBeNull(); + expect(callLog).toEqual(['startPoll', 'stopPoll', 'startPoll']); + expect(sut.status).toBe('degraded'); + }); + + it('rethrows a 4xx client error without entering the mint path', async () => { + const err = Object.assign(new Error('bad request'), { status: 400 }); + mockClientFetch.mockRejectedValue(err); + + const sut = new Clerk(productionPublishableKey); + await expect(sut.load()).rejects.toBe(err); + + expect(mockCreateClientFromJwt).not.toHaveBeenCalled(); + }); + }); }); describe('.signOut()', () => { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 2264ca42f58..16a2248bb2d 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -140,7 +140,7 @@ import type { } from '@clerk/shared/types'; import type { ClerkUI } from '@clerk/shared/ui'; import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url'; -import { allSettled, handleValueOrFn, noop } from '@clerk/shared/utils'; +import { allSettled, handleValueOrFn, noop, timeLimit } from '@clerk/shared/utils'; import type { QueryClient } from '@tanstack/query-core'; import { debugLogger, initDebugLogger } from '@/utils/debug'; @@ -215,6 +215,7 @@ const CANNOT_RENDER_API_KEYS_ORG_DISABLED_ERROR_CODE = 'cannot_render_api_keys_o const CANNOT_RENDER_SELF_SERVE_SSO_DISABLED_ERROR_CODE = 'cannot_render_self_serve_sso_disabled'; const CANNOT_RENDER_CONFIGURE_SSO_EMAIL_ADDRESS_DISABLED_ERROR_CODE = 'cannot_render_configure_sso_email_address_disabled'; +const INITIALIZATION_TIMEOUT_MS = 5_000; const defaultOptions: ClerkOptions = { polling: true, standardBrowser: true, @@ -3174,8 +3175,7 @@ export class Clerk implements ClerkInterface { }); const initClient = async () => { - return Client.getOrCreateInstance() - .fetch() + return timeLimit(Client.getOrCreateInstance().fetch(), INITIALIZATION_TIMEOUT_MS) .then(res => this.updateClient(res)) .catch(async e => { /** @@ -3199,16 +3199,23 @@ export class Clerk implements ClerkInterface { */ this.#authService?.stopPollingForToken(); - // Attempt to grab a fresh token - await this.session - ?.getToken({ skipCache: true }) - // If the token fetch fails, let Clerk be marked as loaded and leave it up to the poller. - .catch(() => null) - .finally(() => { - this.#authService?.startPollingForToken(); - }); + const session = this.session; + if (session) { + session.clearCache(); + await timeLimit(session.getToken(), INITIALIZATION_TIMEOUT_MS) + .catch(() => { + // On timeout the recovery getToken is still in flight with a pending resolver in the cache; + // clear it so the poller's next getToken starts fresh instead of awaiting the abandoned one. + session.clearCache(); + return null; + }) + .finally(() => { + this.#authService?.startPollingForToken(); + }); + } else { + this.#authService?.startPollingForToken(); + } - // Allows for Clerk to be marked as loaded with the client and session created from the JWT. return null; }); }; diff --git a/packages/shared/src/utils/__tests__/timeLimit.test.ts b/packages/shared/src/utils/__tests__/timeLimit.test.ts new file mode 100644 index 00000000000..1886491c8d8 --- /dev/null +++ b/packages/shared/src/utils/__tests__/timeLimit.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { timeLimit } from '../timeLimit'; + +describe('timeLimit', () => { + it('resolves with the value when the promise settles before ms', async () => { + await expect(timeLimit(Promise.resolve('token'), 10_000)).resolves.toBe('token'); + }); + + it('resolves cleanly when the value is a non-promise (e.g. undefined)', async () => { + await expect(timeLimit(undefined, 10_000)).resolves.toBeUndefined(); + }); + + it('rejects with a plain Error (no status) when ms elapses first', async () => { + vi.useFakeTimers(); + try { + const neverSettles = new Promise(() => {}); + const result = timeLimit(neverSettles, 50).catch(error => error); + + await vi.advanceTimersByTimeAsync(50); + + const error = await result; + expect(error).toBeInstanceOf(Error); + expect((error as { status?: unknown }).status).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it('clears the timeout when the value settles first, leaving no pending timer', async () => { + vi.useFakeTimers(); + const clearSpy = vi.spyOn(globalThis, 'clearTimeout'); + try { + await timeLimit(Promise.resolve('fast'), 10_000); + + expect(clearSpy).toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(10_000); + } finally { + clearSpy.mockRestore(); + vi.useRealTimers(); + } + }); +}); diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 4c1e6ec6bef..d6104a73397 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -7,3 +7,4 @@ export * from './runtimeEnvironment'; export { handleValueOrFn } from './handleValueOrFn'; export { runIfFunctionOrReturn } from './runIfFunctionOrReturn'; export { fastDeepMergeAndReplace, fastDeepMergeAndKeep } from './fastDeepMerge'; +export { timeLimit } from './timeLimit'; diff --git a/packages/shared/src/utils/timeLimit.ts b/packages/shared/src/utils/timeLimit.ts new file mode 100644 index 00000000000..9ffed0f1da1 --- /dev/null +++ b/packages/shared/src/utils/timeLimit.ts @@ -0,0 +1,14 @@ +export function timeLimit(value: T | PromiseLike, ms: number): Promise { + let timeoutId: ReturnType; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms); + + // Let a Node process exit while the timeout is still pending; browsers return a number with no unref. + (timeoutId as { unref?: () => void }).unref?.(); + }); + + return Promise.race([Promise.resolve(value), timeoutPromise]).finally(() => { + clearTimeout(timeoutId); + }); +} From 334557b117f9ca9a0df9a5fa14e8e39762d30496 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 3 Jul 2026 11:53:05 +0300 Subject: [PATCH 2/2] fix(clerk-js): mint degraded identity first and abort slow client fetch Bound the load /client fetch and abort it on timeout instead of leaving it in flight. On a slow or failed /client, mint a fresh token first (clearCache + plain getToken, so no force_origin and the minter can still serve it during an origin outage) and derive the degraded identity from that token, falling back to the __session cookie identity only when the mint fails or times out. The mint is bounded by the same timeout. After the degraded load, retry /client once in the background with no timeout and apply it only if nothing else updated the client meanwhile, so a sign-out or a piggybacked client wins over a late response. Stamp the cookie-derived stub user with updated_at: 1 so listener memoization replaces it once the full user arrives, fixing useUser() returning the stub after recovery. Add a timeLimit util to @clerk/shared/utils that optionally aborts an AbortController on timeout. --- .changeset/clerkjs-fail-fast-slow-origin.md | 2 +- integration/tests/pricing-table.test.ts | 2 +- .../clerk-js/src/core/__tests__/clerk.test.ts | 99 ++++++++++++++----- .../src/core/__tests__/jwt-client.test.ts | 28 ++++++ packages/clerk-js/src/core/clerk.ts | 64 ++++++++---- packages/clerk-js/src/core/fapiClient.ts | 5 +- packages/clerk-js/src/core/jwt-client.ts | 4 + packages/clerk-js/src/core/resources/Base.ts | 2 + .../clerk-js/src/core/resources/Client.ts | 4 +- .../src/utils/__tests__/timeLimit.test.ts | 60 +++++++---- packages/shared/src/utils/timeLimit.ts | 13 ++- 11 files changed, 213 insertions(+), 70 deletions(-) create mode 100644 packages/clerk-js/src/core/__tests__/jwt-client.test.ts diff --git a/.changeset/clerkjs-fail-fast-slow-origin.md b/.changeset/clerkjs-fail-fast-slow-origin.md index 960a703efc3..ef68835c0a8 100644 --- a/.changeset/clerkjs-fail-fast-slow-origin.md +++ b/.changeset/clerkjs-fail-fast-slow-origin.md @@ -3,4 +3,4 @@ '@clerk/shared': patch --- -Fail fast when the Clerk Frontend API (FAPI) is slow or unreachable during load. The client request and the load-recovery token mint are now bounded by a timeout, so a cold `Clerk.load()` renders identity from the session cookie in seconds instead of hanging while retries run. Adds a `timeLimit` utility to `@clerk/shared/utils`. +Fail fast when the Clerk Frontend API (FAPI) is slow or unreachable during load. The client request and the load-recovery token mint are now bounded by a timeout, and the timed-out client request is aborted instead of being left in flight. A cold `Clerk.load()` renders identity from a freshly minted session token (falling back to the session cookie if the mint fails) in seconds instead of hanging while retries run. After a degraded load, the client is re-fetched in the background without a time limit, so a slow-but-healthy origin recovers full client data (user profile, other sessions) without a reload. Also fixes hooks like `useUser()` keeping the cookie-derived stub user after full user data arrives. Adds a `timeLimit` utility to `@clerk/shared/utils` that optionally aborts an `AbortController` on timeout. diff --git a/integration/tests/pricing-table.test.ts b/integration/tests/pricing-table.test.ts index 780d7e3a6c6..98b2b0a7e53 100644 --- a/integration/tests/pricing-table.test.ts +++ b/integration/tests/pricing-table.test.ts @@ -595,7 +595,7 @@ testAgainstRunningApps({})('pricing table @billing', ({ app }) => { await u.po.checkout.clickPayOrSubscribe(); await u.po.checkout.confirmAndContinue(); await u.po.pricingTable.startCheckout({ planSlug: 'pro', period: 'monthly' }); - await expect(u.po.page.getByText('- $9.99')).toBeVisible(); + await expect(u.po.page.getByText('-$9.99')).toBeVisible(); await fakeUser.deleteIfExists(); }); diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 7f27a49c9bb..94072ad4674 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -1,4 +1,5 @@ import { ClerkOfflineError, EmailLinkErrorCodeStatus } from '@clerk/shared/error'; +import { createDeferredPromise } from '@clerk/shared/utils'; import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; import type { ActiveSessionResource, @@ -831,7 +832,14 @@ describe('Clerk singleton', () => { signedInSessions: [session], lastActiveSessionId: session.id, }); - const sessionlessClient = () => ({ signedInSessions: [], lastActiveSessionId: null }); + const makeSession = (overrides: Record = {}) => ({ + id: 'sess_1', + status: 'active', + user: {}, + getToken: vi.fn(() => Promise.resolve('fresh-token')), + clearCache: vi.fn(), + ...overrides, + }); // `load()` awaits native crypto (cookie suffix) before scheduling the timeout, so a single // advance can run before the timer exists; advance the budget repeatedly until load settles. @@ -858,7 +866,8 @@ describe('Clerk singleton', () => { stopPollSpy = vi .spyOn(AuthCookieService.prototype, 'stopPollingForToken') .mockImplementation(() => void callLog.push('stopPoll')); - mockCreateClientFromJwt.mockReturnValue(sessionlessClient()); + mockClientFetch.mockClear(); + mockCreateClientFromJwt.mockReturnValue({ signedInSessions: [], lastActiveSessionId: null }); }); afterEach(() => { @@ -878,6 +887,25 @@ describe('Clerk singleton', () => { expect(sut.status).toBe('degraded'); expect(stopPollSpy).toHaveBeenCalled(); expect(startPollSpy).toHaveBeenCalled(); + // The timed-out /client request gets aborted instead of being left in flight. + expect(mockClientFetch.mock.calls[0]?.[0]?.signal?.aborted).toBe(true); + }); + + it('builds the degraded identity from the minted token and falls back to the cookie only if the mint fails', async () => { + const stubSession = makeSession(); + const freshSession = { id: 'sess_1', status: 'active', user: { id: 'user_fresh' } }; + const freshClient = { signedInSessions: [freshSession], lastActiveSessionId: 'sess_1' }; + mockClientFetch.mockRejectedValue(new Error('client fetch failed')); + mockCreateClientFromJwt.mockReturnValueOnce(sessionClient(stubSession)).mockReturnValueOnce(freshClient); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + expect(mockCreateClientFromJwt).toHaveBeenCalledTimes(2); + expect(mockCreateClientFromJwt).toHaveBeenNthCalledWith(2, 'fresh-token'); + expect(sut.client).toBe(freshClient); + expect(sut.session).toBe(freshSession); + expect(sut.status).toBe('degraded'); }); it('clears the token cache and mints without skipCache when the client fetch fails with a session present', async () => { @@ -886,21 +914,13 @@ describe('Clerk singleton', () => { return Promise.resolve('fresh-token'); }); const clearCache = vi.fn(() => void callLog.push('clearCache')); - const session = { - id: 'sess_1', - status: 'active', - user: {}, - getToken, - clearCache, - }; + const session = makeSession({ getToken, clearCache }); mockClientFetch.mockRejectedValue(new Error('client fetch failed')); mockCreateClientFromJwt.mockReturnValue(sessionClient(session)); const sut = new Clerk(productionPublishableKey); await sut.load(); - expect(clearCache).toHaveBeenCalledTimes(1); - expect(getToken).toHaveBeenCalledTimes(1); expect(getToken).toHaveBeenCalledWith(); expect(getToken).not.toHaveBeenCalledWith({ skipCache: true }); expect(callLog).toEqual(['startPoll', 'stopPoll', 'clearCache', 'getToken', 'startPoll']); @@ -914,24 +934,14 @@ describe('Clerk singleton', () => { return new Promise(() => {}); }); const clearCache = vi.fn(() => void callLog.push('clearCache')); - const session = { - id: 'sess_1', - status: 'active', - user: {}, - getToken, - clearCache, - }; + const session = makeSession({ getToken, clearCache }); mockClientFetch.mockRejectedValue(new Error('client fetch failed')); mockCreateClientFromJwt.mockReturnValue(sessionClient(session)); const sut = new Clerk(productionPublishableKey); await pumpUntilSettled(sut.load()); - expect(clearCache).toHaveBeenCalledTimes(2); expect(callLog).toEqual(['startPoll', 'stopPoll', 'clearCache', 'getToken', 'clearCache', 'startPoll']); - const secondClearOrder = clearCache.mock.invocationCallOrder[1]; - const lastStartPollOrder = startPollSpy.mock.invocationCallOrder.at(-1); - expect(secondClearOrder).toBeLessThan(lastStartPollOrder!); expect(sut.status).toBe('degraded'); }); @@ -955,6 +965,51 @@ describe('Clerk singleton', () => { expect(mockCreateClientFromJwt).not.toHaveBeenCalled(); }); + + it('re-fetches /client in the background after a degraded load and applies the late response', async () => { + vi.useFakeTimers(); + const stubSession = makeSession(); + const realSession = { id: 'sess_1', status: 'active', user: { id: 'user_real' } }; + const realClient = { id: 'client_real', signedInSessions: [realSession], lastActiveSessionId: 'sess_1' }; + const refetch = createDeferredPromise(); + mockClientFetch.mockRejectedValueOnce(new Error('client fetch failed')).mockReturnValueOnce(refetch.promise); + mockCreateClientFromJwt.mockReturnValue(sessionClient(stubSession)); + + const sut = new Clerk(productionPublishableKey); + await pumpUntilSettled(sut.load()); + + expect(sut.status).toBe('degraded'); + expect(mockClientFetch).toHaveBeenCalledTimes(2); + + // The background retry is not bounded by INITIALIZATION_TIMEOUT_MS. + await vi.advanceTimersByTimeAsync(60_000); + refetch.resolve(realClient); + await vi.advanceTimersByTimeAsync(0); + + expect(sut.client).toBe(realClient); + expect(sut.session).toBe(realSession); + }); + + it('discards the background /client response when something else updated the client while it was in flight', async () => { + const stubSession = makeSession(); + const refetch = createDeferredPromise(); + mockClientFetch.mockRejectedValueOnce(new Error('client fetch failed')).mockReturnValueOnce(refetch.promise); + mockCreateClientFromJwt.mockReturnValue(sessionClient(stubSession)); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + // Something newer lands while the background retry is still in flight, e.g. a sign-out + // or a mutation's piggybacked client. + const interimClient = { id: 'client_interim', signedInSessions: [], lastActiveSessionId: null }; + sut.updateClient(interimClient as any); + + const staleClient = { id: 'client_stale', signedInSessions: [stubSession], lastActiveSessionId: 'sess_1' }; + refetch.resolve(staleClient); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(sut.client).toBe(interimClient); + }); }); }); diff --git a/packages/clerk-js/src/core/__tests__/jwt-client.test.ts b/packages/clerk-js/src/core/__tests__/jwt-client.test.ts new file mode 100644 index 00000000000..b9a2f3e4c22 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/jwt-client.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { mockJwt } from '@/test/core-fixtures'; + +import { createClientFromJwt } from '../jwt-client'; + +describe('createClientFromJwt', () => { + it('creates a client with a session and user derived from the JWT claims', () => { + const client = createClientFromJwt(mockJwt); + + expect(client.lastActiveSessionId).toBe('sess_2GbDB4enNdCa5vS1zpC3Xzg9tK9'); + expect(client.signedInSessions[0]?.id).toBe('sess_2GbDB4enNdCa5vS1zpC3Xzg9tK9'); + expect(client.signedInSessions[0]?.user?.id).toBe('user_2GIpXOEpVyJw51rkZn9Kmnc6Sxr'); + }); + + it('stamps the stub user with an ancient updatedAt so the real user always replaces it in memoized listeners', () => { + const client = createClientFromJwt(mockJwt); + + expect(client.signedInSessions[0]?.user?.updatedAt?.getTime()).toBe(1); + }); + + it('returns an empty client when the JWT is missing', () => { + const client = createClientFromJwt(undefined); + + expect(client.signedInSessions).toEqual([]); + expect(client.lastActiveSessionId).toBeNull(); + }); +}); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 16a2248bb2d..87d35bb1593 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -215,7 +215,9 @@ const CANNOT_RENDER_API_KEYS_ORG_DISABLED_ERROR_CODE = 'cannot_render_api_keys_o const CANNOT_RENDER_SELF_SERVE_SSO_DISABLED_ERROR_CODE = 'cannot_render_self_serve_sso_disabled'; const CANNOT_RENDER_CONFIGURE_SSO_EMAIL_ADDRESS_DISABLED_ERROR_CODE = 'cannot_render_configure_sso_email_address_disabled'; -const INITIALIZATION_TIMEOUT_MS = 5_000; +// Bounds a single origin request at load; a slow response gets no fapiClient retry, +// so this needs to sit above real-world /client latency including cold-mobile DNS+TLS. +const INITIALIZATION_TIMEOUT_MS = 7_000; const defaultOptions: ClerkOptions = { polling: true, standardBrowser: true, @@ -268,6 +270,7 @@ export class Clerk implements ClerkInterface { #fapiClient: FapiClient; #instanceType?: InstanceType; #status: ClerkInterface['status'] = 'loading'; + #clientUpdateGeneration = 0; #listeners: Array<(emission: Resources) => void> = []; #navigationListeners: Array<() => void> = []; #options: ClerkOptions = {}; @@ -2906,6 +2909,7 @@ export class Clerk implements ClerkInterface { // and emitting, library consumers that both read state directly and set up listeners // could end up in a inconsistent state. updateClient = (newClient: ClientResource, options?: { __internal_dangerouslySkipEmit?: boolean }): void => { + this.#clientUpdateGeneration++; if (!this.client) { // This is the first time client is being // set, so we also need to set session @@ -3175,7 +3179,14 @@ export class Clerk implements ClerkInterface { }); const initClient = async () => { - return timeLimit(Client.getOrCreateInstance().fetch(), INITIALIZATION_TIMEOUT_MS) + // Abort the /client request on timeout so it stops running instead of settling on a + // detached instance later; the background retry below owns recovery from then on. + const clientFetchController = new AbortController(); + return timeLimit( + Client.getOrCreateInstance().fetch({ signal: clientFetchController.signal }), + INITIALIZATION_TIMEOUT_MS, + clientFetchController, + ) .then(res => this.updateClient(res)) .catch(async e => { /** @@ -3188,33 +3199,48 @@ export class Clerk implements ClerkInterface { ++initializationDegradedCounter; - const jwtInCookie = this.#authService?.getSessionCookie(); - const localClient = createClientFromJwt(jwtInCookie); - - this.updateClient(localClient); - /** * In most scenarios we want the poller to stop while we are fetching a fresh token during an outage. * We want to avoid having the below `getToken()` retrying at the same time as the poller. */ this.#authService?.stopPollingForToken(); - const session = this.session; + const jwtInCookie = this.#authService?.getSessionCookie(); + const localClient = createClientFromJwt(jwtInCookie); + const session = this.#defaultSession(localClient); + if (session) { + // Prefer minting a fresh token for the degraded identity: the minter can serve it + // during an origin outage and its claims are fresher than the cookie's. Fall back + // to the cookie identity only when the mint fails or times out. session.clearCache(); - await timeLimit(session.getToken(), INITIALIZATION_TIMEOUT_MS) - .catch(() => { - // On timeout the recovery getToken is still in flight with a pending resolver in the cache; - // clear it so the poller's next getToken starts fresh instead of awaiting the abandoned one. - session.clearCache(); - return null; - }) - .finally(() => { - this.#authService?.startPollingForToken(); - }); + const freshJwt = await timeLimit(session.getToken(), INITIALIZATION_TIMEOUT_MS).catch(() => { + // On timeout the recovery getToken is still in flight with a pending resolver in the cache; + // clear it so the poller's next getToken starts fresh instead of awaiting the abandoned one. + session.clearCache(); + return null; + }); + this.updateClient(freshJwt ? createClientFromJwt(freshJwt) : localClient); } else { - this.#authService?.startPollingForToken(); + this.updateClient(localClient); } + this.#authService?.startPollingForToken(); + + // Retry /client in the background through the normal fetch flow (network-level + // retries, no time limit) now that load no longer blocks on it. The response is + // applied only if nothing else updated the client while it was in flight; anything + // newer (a sign-out, a mutation's piggybacked client) must win over the late response. + // A 5xx failure is not retried (fapiClient only retries network errors); in that + // case the client heals via the piggybacked client of the next mutation. + const clientGenerationAtDispatch = this.#clientUpdateGeneration; + void Client.getOrCreateInstance() + .fetch() + .then(res => { + if (this.#clientUpdateGeneration === clientGenerationAtDispatch) { + this.updateClient(res); + } + }) + .catch(noop); return null; }); diff --git a/packages/clerk-js/src/core/fapiClient.ts b/packages/clerk-js/src/core/fapiClient.ts index 412c708b871..c0595d20852 100644 --- a/packages/clerk-js/src/core/fapiClient.ts +++ b/packages/clerk-js/src/core/fapiClient.ts @@ -257,8 +257,9 @@ export function createFapiClient(options: FapiClientOptions): FapiClient { initialDelay: 700, maxDelayBetweenRetries: 5000, shouldRetry: (_: unknown, iterations: number) => { - // We want to retry only GET requests, as other methods are not idempotent. - return overwrittenRequestMethod === 'GET' && iterations < maxTries; + // We want to retry only GET requests, as other methods are not idempotent, + // and stop as soon as the caller aborted the request. + return overwrittenRequestMethod === 'GET' && iterations < maxTries && !fetchOpts.signal?.aborted; }, onBeforeRetry: (iteration: number): void => { // Add the retry attempt to the query string params. diff --git a/packages/clerk-js/src/core/jwt-client.ts b/packages/clerk-js/src/core/jwt-client.ts index 1eb19a24b63..9eafe5d088c 100644 --- a/packages/clerk-js/src/core/jwt-client.ts +++ b/packages/clerk-js/src/core/jwt-client.ts @@ -71,6 +71,10 @@ export function createClientFromJwt(jwt: string | undefined | null): Client { user: { object: 'user', id: userId, + // Epoch 1, not 0: unixEpochToDate treats 0 as "now", and a "now" timestamp makes + // memoizeListenerCallback consider this stub newer than the real user fetched later, + // so listeners would keep rendering the stub after the full client arrives. + updated_at: 1, organization_memberships: orgId && orgSlug && orgRole ? [ diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index df04fbad5fd..f53bb6c6625 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -19,6 +19,7 @@ export type BaseFetchOptions = ClerkResourceReloadParams & { forceUpdateClient?: boolean; skipUpdateClient?: boolean; fetchMaxTries?: number; + signal?: AbortSignal; }; export type BaseMutateParams = { @@ -207,6 +208,7 @@ export abstract class BaseResource { method: 'GET', path: this.path(), rotatingTokenNonce: opts.rotatingTokenNonce, + signal: opts.signal, }, opts, ); diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 6b690c7261c..34287d57272 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -73,8 +73,8 @@ export class Client extends BaseResource implements ClientResource { return this._basePut(); } - fetch({ fetchMaxTries }: { fetchMaxTries?: number } = {}): Promise { - return this._baseGet({ fetchMaxTries }); + fetch({ fetchMaxTries, signal }: { fetchMaxTries?: number; signal?: AbortSignal } = {}): Promise { + return this._baseGet({ fetchMaxTries, signal }); } async destroy(): Promise { diff --git a/packages/shared/src/utils/__tests__/timeLimit.test.ts b/packages/shared/src/utils/__tests__/timeLimit.test.ts index 1886491c8d8..1d82c72a495 100644 --- a/packages/shared/src/utils/__tests__/timeLimit.test.ts +++ b/packages/shared/src/utils/__tests__/timeLimit.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { timeLimit } from '../timeLimit'; describe('timeLimit', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + it('resolves with the value when the promise settles before ms', async () => { await expect(timeLimit(Promise.resolve('token'), 10_000)).resolves.toBe('token'); }); @@ -13,32 +18,45 @@ describe('timeLimit', () => { it('rejects with a plain Error (no status) when ms elapses first', async () => { vi.useFakeTimers(); - try { - const neverSettles = new Promise(() => {}); - const result = timeLimit(neverSettles, 50).catch(error => error); - - await vi.advanceTimersByTimeAsync(50); - - const error = await result; - expect(error).toBeInstanceOf(Error); - expect((error as { status?: unknown }).status).toBeUndefined(); - } finally { - vi.useRealTimers(); - } + const neverSettles = new Promise(() => {}); + const result = timeLimit(neverSettles, 50).catch(error => error); + + await vi.advanceTimersByTimeAsync(50); + + const error = await result; + expect(error).toBeInstanceOf(Error); + expect((error as { status?: unknown }).status).toBeUndefined(); }); it('clears the timeout when the value settles first, leaving no pending timer', async () => { vi.useFakeTimers(); const clearSpy = vi.spyOn(globalThis, 'clearTimeout'); - try { - await timeLimit(Promise.resolve('fast'), 10_000); - expect(clearSpy).toHaveBeenCalled(); + await timeLimit(Promise.resolve('fast'), 10_000); + + expect(clearSpy).toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(10_000); + }); + + it('aborts the provided controller with the timeout error when ms elapses first', async () => { + vi.useFakeTimers(); + const abort = vi.fn(); + const neverSettles = new Promise(() => {}); + const result = timeLimit(neverSettles, 50, { abort }).catch(error => error); + + await vi.advanceTimersByTimeAsync(50); + + const error = await result; + expect(abort).toHaveBeenCalledTimes(1); + expect(abort).toHaveBeenCalledWith(error); + }); + + it('does not abort when the value settles before ms', async () => { + const abort = vi.fn(); + + await timeLimit(Promise.resolve('fast'), 10_000, { abort }); - await vi.advanceTimersByTimeAsync(10_000); - } finally { - clearSpy.mockRestore(); - vi.useRealTimers(); - } + expect(abort).not.toHaveBeenCalled(); }); }); diff --git a/packages/shared/src/utils/timeLimit.ts b/packages/shared/src/utils/timeLimit.ts index 9ffed0f1da1..ccdd6758d3d 100644 --- a/packages/shared/src/utils/timeLimit.ts +++ b/packages/shared/src/utils/timeLimit.ts @@ -1,8 +1,17 @@ -export function timeLimit(value: T | PromiseLike, ms: number): Promise { +export function timeLimit( + value: T | PromiseLike, + ms: number, + controller?: Pick, +): Promise { let timeoutId: ReturnType; const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms); + timeoutId = setTimeout(() => { + const error = new Error(`Timed out after ${ms}ms`); + // Abort the underlying operation (e.g. a fetch) so it stops running instead of settling later. + controller?.abort(error); + reject(error); + }, ms); // Let a Node process exit while the timeout is still pending; browsers return a number with no unref. (timeoutId as { unref?: () => void }).unref?.();