Skip to content

Commit d8fc1d7

Browse files
authored
fix(clerk-js): fail fast when the origin is slow at load (#9065)
1 parent 1d0e78c commit d8fc1d7

12 files changed

Lines changed: 334 additions & 21 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
'@clerk/shared': patch
4+
---
5+
6+
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.

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Clerk's JavaScript SDK and library monorepo.
99
- Use `pnpm` only. `npm` and `yarn` are blocked by `preinstall`. Node `>=24.15`, pnpm `>=10.33`.
1010
- Every PR needs a changeset. `pnpm changeset` for package changes, `pnpm changeset:empty` for tooling/repo-only. Empty changesets are two `---` delimiters with no body. A changeset is a changelog entry for users upgrading the package, not a summary of the work done in the PR. Describe the user-facing change (what changed for someone consuming the library and how it affects them) rather than the implementation details of the diff. If a change has no user-facing impact, use an empty changeset.
1111
- Commits must be conventional: `type(scope):` (commitlint enforces, on the PR title). `scope` is the package name without `@clerk/`, or `repo` / `release` / `e2e` / `ci` / `*`. `clerk-js` uses scope `js` (`clerk-js` is also accepted). Scope is mandatory; `docs` is a type, not a scope.
12+
- Keep code comments minimal. Do not add a comment unless it is critical to explain WHY a non-obvious change was made; never restate what the code does. When one is warranted, keep it to a single terse line, not a verbose multi-line block.
1213

1314
## References
1415

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

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
SignUpJSON,
99
TokenResource,
1010
} from '@clerk/shared/types';
11+
import { createDeferredPromise } from '@clerk/shared/utils';
1112
import { waitFor } from '@testing-library/react';
1213
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test, vi } from 'vitest';
1314

@@ -26,6 +27,9 @@ const mockEnvironmentFetch = vi.fn(() => Promise.resolve({}));
2627
vi.mock('../resources/Client');
2728
vi.mock('../resources/Environment');
2829

30+
const { mockCreateClientFromJwt } = vi.hoisted(() => ({ mockCreateClientFromJwt: vi.fn() }));
31+
vi.mock('../jwt-client', () => ({ createClientFromJwt: mockCreateClientFromJwt }));
32+
2933
vi.mock('../auth/devBrowser', () => ({
3034
createDevBrowser: (): DevBrowser => ({
3135
clear: vi.fn(),
@@ -818,6 +822,176 @@ describe('Clerk singleton', () => {
818822
});
819823
},
820824
);
825+
826+
describe('when the client fetch fails or hangs at load', () => {
827+
let startPollSpy: ReturnType<typeof vi.spyOn>;
828+
let stopPollSpy: ReturnType<typeof vi.spyOn>;
829+
let callLog: string[];
830+
831+
const sessionClient = (session: any) => ({
832+
signedInSessions: [session],
833+
lastActiveSessionId: session.id,
834+
});
835+
const makeSession = (overrides: Record<string, unknown> = {}) => ({
836+
id: 'sess_1',
837+
status: 'active',
838+
user: {},
839+
getToken: vi.fn(() => Promise.resolve('fresh-token')),
840+
clearCache: vi.fn(),
841+
...overrides,
842+
});
843+
844+
// `load()` awaits native crypto (cookie suffix) before scheduling the timeout, so a single
845+
// advance can run before the timer exists; advance the budget repeatedly until load settles.
846+
const pumpUntilSettled = async (promise: Promise<unknown>) => {
847+
let settled = false;
848+
const tracked = promise.then(
849+
v => ((settled = true), v),
850+
e => ((settled = true), Promise.reject(e)),
851+
);
852+
for (let i = 0; i < 20 && !settled; i++) {
853+
await vi.advanceTimersByTimeAsync(5000);
854+
}
855+
await tracked;
856+
};
857+
858+
beforeEach(async () => {
859+
callLog = [];
860+
// Import at runtime (not top-level) so this module does not reorder the
861+
// static graph and break the auto-mocked Environment/Client resources.
862+
const { AuthCookieService } = await import('../auth/AuthCookieService');
863+
startPollSpy = vi
864+
.spyOn(AuthCookieService.prototype, 'startPollingForToken')
865+
.mockImplementation(() => void callLog.push('startPoll'));
866+
stopPollSpy = vi
867+
.spyOn(AuthCookieService.prototype, 'stopPollingForToken')
868+
.mockImplementation(() => void callLog.push('stopPoll'));
869+
mockClientFetch.mockClear();
870+
mockCreateClientFromJwt.mockReturnValue({ signedInSessions: [], lastActiveSessionId: null });
871+
});
872+
873+
afterEach(() => {
874+
startPollSpy.mockRestore();
875+
stopPollSpy.mockRestore();
876+
mockCreateClientFromJwt.mockReset();
877+
vi.useRealTimers();
878+
});
879+
880+
it('fails fast and marks Clerk degraded when the client fetch hangs', async () => {
881+
vi.useFakeTimers();
882+
// Only the primary fetch hangs; the background retry must resolve so it can't stall the test.
883+
mockClientFetch
884+
.mockReturnValueOnce(new Promise(() => {}))
885+
.mockResolvedValue({ signedInSessions: [], lastActiveSessionId: null });
886+
887+
const sut = new Clerk(productionPublishableKey);
888+
await pumpUntilSettled(sut.load());
889+
890+
expect(sut.status).toBe('degraded');
891+
expect(stopPollSpy).toHaveBeenCalled();
892+
expect(startPollSpy).toHaveBeenCalled();
893+
expect(mockClientFetch.mock.calls[0]?.[0]?.abortSignal?.aborted).toBe(true);
894+
});
895+
896+
it('builds the degraded identity from the minted token and falls back to the cookie only if the mint fails', async () => {
897+
const stubSession = makeSession();
898+
const freshSession = { id: 'sess_1', status: 'active', user: { id: 'user_fresh' } };
899+
const freshClient = { signedInSessions: [freshSession], lastActiveSessionId: 'sess_1' };
900+
mockClientFetch.mockRejectedValue(new Error('client fetch failed'));
901+
mockCreateClientFromJwt.mockReturnValueOnce(sessionClient(stubSession)).mockReturnValueOnce(freshClient);
902+
903+
const sut = new Clerk(productionPublishableKey);
904+
await sut.load();
905+
906+
expect(mockCreateClientFromJwt).toHaveBeenCalledTimes(2);
907+
expect(mockCreateClientFromJwt).toHaveBeenNthCalledWith(2, 'fresh-token');
908+
expect(sut.client).toBe(freshClient);
909+
expect(sut.session).toBe(freshSession);
910+
expect(sut.status).toBe('degraded');
911+
});
912+
913+
it('clears the token cache and mints without skipCache when the client fetch fails with a session present', async () => {
914+
const getToken = vi.fn(() => {
915+
callLog.push('getToken');
916+
return Promise.resolve('fresh-token');
917+
});
918+
const clearCache = vi.fn(() => void callLog.push('clearCache'));
919+
const session = makeSession({ getToken, clearCache });
920+
mockClientFetch.mockRejectedValue(new Error('client fetch failed'));
921+
mockCreateClientFromJwt.mockReturnValue(sessionClient(session));
922+
923+
const sut = new Clerk(productionPublishableKey);
924+
await sut.load();
925+
926+
expect(getToken).toHaveBeenCalledWith();
927+
expect(getToken).not.toHaveBeenCalledWith({ skipCache: true });
928+
expect(callLog).toEqual(['startPoll', 'stopPoll', 'clearCache', 'getToken', 'startPoll']);
929+
expect(sut.status).toBe('degraded');
930+
});
931+
932+
it('re-clears the token cache before restarting the poller when the recovery mint hangs', async () => {
933+
vi.useFakeTimers();
934+
const getToken = vi.fn(() => {
935+
callLog.push('getToken');
936+
return new Promise<string>(() => {});
937+
});
938+
const clearCache = vi.fn(() => void callLog.push('clearCache'));
939+
const session = makeSession({ getToken, clearCache });
940+
mockClientFetch.mockRejectedValue(new Error('client fetch failed'));
941+
mockCreateClientFromJwt.mockReturnValue(sessionClient(session));
942+
943+
const sut = new Clerk(productionPublishableKey);
944+
await pumpUntilSettled(sut.load());
945+
946+
expect(callLog).toEqual(['startPoll', 'stopPoll', 'clearCache', 'getToken', 'clearCache', 'startPoll']);
947+
expect(sut.status).toBe('degraded');
948+
});
949+
950+
it('renders the empty client without minting when there is no session cookie', async () => {
951+
mockClientFetch.mockRejectedValue(new Error('client fetch failed'));
952+
953+
const sut = new Clerk(productionPublishableKey);
954+
await sut.load();
955+
956+
expect(sut.session).toBeNull();
957+
expect(callLog).toEqual(['startPoll', 'stopPoll', 'startPoll']);
958+
expect(sut.status).toBe('degraded');
959+
});
960+
961+
it('rethrows a 4xx client error without entering the mint path', async () => {
962+
const err = Object.assign(new Error('bad request'), { status: 400 });
963+
mockClientFetch.mockRejectedValue(err);
964+
965+
const sut = new Clerk(productionPublishableKey);
966+
await expect(sut.load()).rejects.toBe(err);
967+
968+
expect(mockCreateClientFromJwt).not.toHaveBeenCalled();
969+
});
970+
971+
it('re-fetches /client in the background after a degraded load and applies the late response', async () => {
972+
vi.useFakeTimers();
973+
const stubSession = makeSession();
974+
const realSession = { id: 'sess_1', status: 'active', user: { id: 'user_real' } };
975+
const realClient = { id: 'client_real', signedInSessions: [realSession], lastActiveSessionId: 'sess_1' };
976+
const refetch = createDeferredPromise();
977+
mockClientFetch.mockRejectedValueOnce(new Error('client fetch failed')).mockReturnValueOnce(refetch.promise);
978+
mockCreateClientFromJwt.mockReturnValue(sessionClient(stubSession));
979+
980+
const sut = new Clerk(productionPublishableKey);
981+
await pumpUntilSettled(sut.load());
982+
983+
expect(sut.status).toBe('degraded');
984+
expect(mockClientFetch).toHaveBeenCalledTimes(2);
985+
986+
// The background retry is not bounded by INITIALIZATION_TIMEOUT_MS.
987+
await vi.advanceTimersByTimeAsync(60_000);
988+
refetch.resolve(realClient);
989+
await vi.advanceTimersByTimeAsync(0);
990+
991+
expect(sut.client).toBe(realClient);
992+
expect(sut.session).toBe(realSession);
993+
});
994+
});
821995
});
822996

823997
describe('updateSessionCookie monotonic backstop', () => {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { mockJwt } from '@/test/core-fixtures';
4+
5+
import { createClientFromJwt } from '../jwt-client';
6+
7+
describe('createClientFromJwt', () => {
8+
it('creates a client with a session and user derived from the JWT claims', () => {
9+
const client = createClientFromJwt(mockJwt);
10+
11+
expect(client.lastActiveSessionId).toBe('sess_2GbDB4enNdCa5vS1zpC3Xzg9tK9');
12+
expect(client.signedInSessions[0]?.id).toBe('sess_2GbDB4enNdCa5vS1zpC3Xzg9tK9');
13+
expect(client.signedInSessions[0]?.user?.id).toBe('user_2GIpXOEpVyJw51rkZn9Kmnc6Sxr');
14+
});
15+
16+
it('stamps the stub user with an ancient updatedAt so the real user always replaces it in memoized listeners', () => {
17+
const client = createClientFromJwt(mockJwt);
18+
19+
expect(client.signedInSessions[0]?.user?.updatedAt?.getTime()).toBe(1);
20+
});
21+
22+
it('returns an empty client when the JWT is missing', () => {
23+
const client = createClientFromJwt(undefined);
24+
25+
expect(client.signedInSessions).toEqual([]);
26+
expect(client.lastActiveSessionId).toBeNull();
27+
});
28+
});

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

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ import type {
140140
} from '@clerk/shared/types';
141141
import type { ClerkUI } from '@clerk/shared/ui';
142142
import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url';
143-
import { allSettled, handleValueOrFn, noop } from '@clerk/shared/utils';
143+
import { allSettled, handleValueOrFn, noop, timeLimit } from '@clerk/shared/utils';
144144
import type { QueryClient } from '@tanstack/query-core';
145145

146146
import { debugLogger, initDebugLogger } from '@/utils/debug';
@@ -215,6 +215,7 @@ const CANNOT_RENDER_API_KEYS_ORG_DISABLED_ERROR_CODE = 'cannot_render_api_keys_o
215215
const CANNOT_RENDER_SELF_SERVE_SSO_DISABLED_ERROR_CODE = 'cannot_render_self_serve_sso_disabled';
216216
const CANNOT_RENDER_CONFIGURE_SSO_EMAIL_ADDRESS_DISABLED_ERROR_CODE =
217217
'cannot_render_configure_sso_email_address_disabled';
218+
const INITIALIZATION_TIMEOUT_MS = 7_000;
218219
const defaultOptions: ClerkOptions = {
219220
polling: true,
220221
standardBrowser: true,
@@ -3245,8 +3246,13 @@ export class Clerk implements ClerkInterface {
32453246
});
32463247

32473248
const initClient = async () => {
3248-
return Client.getOrCreateInstance()
3249-
.fetch()
3249+
// Abort the /client request on timeout so clerkjs loading does not hang
3250+
const clientFetchController = new AbortController();
3251+
return timeLimit(
3252+
Client.getOrCreateInstance().fetch({ abortSignal: clientFetchController.signal }),
3253+
INITIALIZATION_TIMEOUT_MS,
3254+
clientFetchController,
3255+
)
32503256
.then(res => this.updateClient(res))
32513257
.catch(async e => {
32523258
/**
@@ -3259,27 +3265,32 @@ export class Clerk implements ClerkInterface {
32593265

32603266
++initializationDegradedCounter;
32613267

3262-
const jwtInCookie = this.#authService?.getSessionCookie();
3263-
const localClient = createClientFromJwt(jwtInCookie);
3264-
3265-
this.updateClient(localClient);
3266-
32673268
/**
32683269
* In most scenarios we want the poller to stop while we are fetching a fresh token during an outage.
32693270
* We want to avoid having the below `getToken()` retrying at the same time as the poller.
32703271
*/
32713272
this.#authService?.stopPollingForToken();
32723273

3273-
// Attempt to grab a fresh token
3274-
await this.session
3275-
?.getToken({ skipCache: true })
3276-
// If the token fetch fails, let Clerk be marked as loaded and leave it up to the poller.
3277-
.catch(() => null)
3278-
.finally(() => {
3279-
this.#authService?.startPollingForToken();
3280-
});
3274+
try {
3275+
const session = this.#defaultSession(createClientFromJwt(this.#authService?.getSessionCookie()));
3276+
// Prefer minting a fresh token as its claims are fresher than the cookie's
3277+
session?.clearCache();
3278+
const jwt = session
3279+
? await timeLimit(session.getToken(), INITIALIZATION_TIMEOUT_MS).catch(() => {
3280+
session.clearCache();
3281+
return this.#authService?.getSessionCookie();
3282+
})
3283+
: this.#authService?.getSessionCookie();
3284+
this.updateClient(createClientFromJwt(jwt));
3285+
} finally {
3286+
this.#authService?.startPollingForToken();
3287+
}
3288+
3289+
void Client.getOrCreateInstance()
3290+
.fetch()
3291+
.then(res => this.updateClient(res))
3292+
.catch(noop);
32813293

3282-
// Allows for Clerk to be marked as loaded with the client and session created from the JWT.
32833294
return null;
32843295
});
32853296
};

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,9 @@ export function createFapiClient(options: FapiClientOptions): FapiClient {
257257
initialDelay: 700,
258258
maxDelayBetweenRetries: 5000,
259259
shouldRetry: (_: unknown, iterations: number) => {
260-
// We want to retry only GET requests, as other methods are not idempotent.
261-
return overwrittenRequestMethod === 'GET' && iterations < maxTries;
260+
// We want to retry only GET requests, as other methods are not idempotent,
261+
// and stop as soon as the caller aborted the request.
262+
return overwrittenRequestMethod === 'GET' && iterations < maxTries && !fetchOpts.signal?.aborted;
262263
},
263264
onBeforeRetry: (iteration: number): void => {
264265
// Add the retry attempt to the query string params.

packages/clerk-js/src/core/jwt-client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ export function createClientFromJwt(jwt: string | undefined | null): Client {
7171
user: {
7272
object: 'user',
7373
id: userId,
74+
// Epoch 1, not 0: unixEpochToDate treats 0 as "now", and a "now" timestamp makes
75+
// memoizeListenerCallback consider this stub newer than the real user fetched later,
76+
// so listeners would keep rendering the stub after the full client arrives.
77+
updated_at: 1,
7478
organization_memberships:
7579
orgId && orgSlug && orgRole
7680
? [

packages/clerk-js/src/core/resources/Base.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type BaseFetchOptions = ClerkResourceReloadParams & {
1919
forceUpdateClient?: boolean;
2020
skipUpdateClient?: boolean;
2121
fetchMaxTries?: number;
22+
abortSignal?: AbortSignal;
2223
};
2324

2425
export type BaseMutateParams = {
@@ -207,6 +208,7 @@ export abstract class BaseResource {
207208
method: 'GET',
208209
path: this.path(),
209210
rotatingTokenNonce: opts.rotatingTokenNonce,
211+
signal: opts.abortSignal,
210212
},
211213
opts,
212214
);

packages/clerk-js/src/core/resources/Client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ export class Client extends BaseResource implements ClientResource {
7373
return this._basePut();
7474
}
7575

76-
fetch({ fetchMaxTries }: { fetchMaxTries?: number } = {}): Promise<this> {
77-
return this._baseGet({ fetchMaxTries });
76+
fetch({ fetchMaxTries, abortSignal }: { fetchMaxTries?: number; abortSignal?: AbortSignal } = {}): Promise<this> {
77+
return this._baseGet({ fetchMaxTries, abortSignal });
7878
}
7979

8080
async destroy(): Promise<void> {

0 commit comments

Comments
 (0)