Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
aec61e8
fix: init retry logic
jacekradko Nov 3, 2025
0d7919b
Merge branch 'main' into fix/init-retry-logic
jacekradko Nov 24, 2025
8e58d10
Merge branch 'main' into fix/init-retry-logic
jacekradko Nov 26, 2025
00c834d
wip
jacekradko Nov 26, 2025
dbf02e9
guard against invalid maxAttempts
jacekradko Nov 26, 2025
b15a32c
guard against mounting components with missing env
jacekradko Nov 26, 2025
b55bf67
changeset
jacekradko Nov 26, 2025
2a0e348
Merge branch 'main' into fix/init-retry-logic
jacekradko Nov 26, 2025
e6b28f3
Merge branch 'main' into fix/init-retry-logic
jacekradko Nov 28, 2025
95e6d64
wip
jacekradko Nov 28, 2025
b739aab
Merge branch 'main' into fix/init-retry-logic
jacekradko Dec 1, 2025
2239274
wip
jacekradko Dec 1, 2025
f2bf4a9
wip
jacekradko Dec 1, 2025
ee40019
Merge branch 'main' into fix/init-retry-logic
jacekradko Dec 3, 2025
b523532
Merge branch 'main' into fix/init-retry-logic
jacekradko Dec 9, 2025
17bcea8
Merge branch 'main' into fix/init-retry-logic
jacekradko Dec 9, 2025
c742214
wip
jacekradko Dec 9, 2025
00b626a
wip
jacekradko Dec 9, 2025
2db92aa
Merge branch 'main' into fix/init-retry-logic
jacekradko Dec 10, 2025
645142b
Merge branch 'main' into fix/init-retry-logic
jacekradko Jan 30, 2026
5529cf3
fix(clerk-js): refactor init retry logic with exponential backoff
jacekradko Jan 30, 2026
11b4913
merge main into fix/init-retry-logic
jacekradko Mar 23, 2026
b6f68e2
fix: use correct #clerkUI casing after merge
jacekradko Mar 23, 2026
95c4f2f
Merge branch 'main' into fix/init-retry-logic
jacekradko May 13, 2026
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
5 changes: 5 additions & 0 deletions .changeset/easy-bars-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Refactors Clerk initialization retry logic to use a dedicated withRetry utility with exponential backoff
182 changes: 181 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 { ClerkOfflineError, EmailLinkErrorCodeStatus } from '@clerk/shared/error';
import { ClerkOfflineError, ClerkRuntimeError, EmailLinkErrorCodeStatus } from '@clerk/shared/error';
import type {
ActiveSessionResource,
PendingSessionResource,
Expand All @@ -13,8 +13,11 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test,
import { mockJwt } from '@/test/core-fixtures';

import { mockNativeRuntime } from '../../test/utils';
import * as localStorageModule from '../../utils/localStorage';
import { AuthCookieService } from '../auth/AuthCookieService';
import type { DevBrowser } from '../auth/devBrowser';
import { Clerk } from '../clerk';
import * as errorsModule from '../errors';
import { eventBus, events } from '../events';
import type { DisplayConfig, Organization } from '../resources/internal';
import { BaseResource, Client, Environment, SignIn, SignUp } from '../resources/internal';
Expand Down Expand Up @@ -158,6 +161,183 @@ describe('Clerk singleton', () => {
});
});

describe('load retry behavior', () => {
let originalMountComponentRenderer: typeof Clerk.mountComponentRenderer;

const createMockAuthService = () => ({
decorateUrlWithDevBrowserToken: vi.fn((url: URL) => url),
getSessionCookie: vi.fn(() => null),
handleUnauthenticatedDevBrowser: vi.fn(() => Promise.resolve()),
isSignedOut: vi.fn(() => false),
setClientUatCookieForDevelopmentInstances: vi.fn(),
startPollingForToken: vi.fn(),
stopPollingForToken: vi.fn(),
});

const createMockComponentControls = () => {
const componentInstance = {
mountImpersonationFab: vi.fn(),
updateProps: vi.fn(),
};

return {
ensureMounted: vi.fn().mockResolvedValue(componentInstance),
prioritizedOn: vi.fn(),
};
};

beforeEach(() => {
originalMountComponentRenderer = Clerk.mountComponentRenderer;
});

afterEach(() => {
Clerk.mountComponentRenderer = originalMountComponentRenderer;
vi.useRealTimers();
});

it('retries once when dev browser authentication is lost', async () => {
vi.useFakeTimers();

const mockAuthService = createMockAuthService();
const authCreateSpy = vi
.spyOn(AuthCookieService, 'create')
.mockResolvedValue(mockAuthService as unknown as AuthCookieService);

const componentControls = createMockComponentControls();
const devBrowserError = Object.assign(new Error('dev browser unauthenticated'), {
errors: [{ code: 'dev_browser_unauthenticated' }],
status: 401,
});

const mountSpy = vi
.fn<NonNullable<typeof Clerk.mountComponentRenderer>>()
.mockImplementationOnce(() => {
throw devBrowserError;
})
.mockReturnValue(componentControls);

Clerk.mountComponentRenderer = mountSpy;
mockClientFetch.mockClear();

const sut = new Clerk(productionPublishableKey);

try {
const loadPromise = sut.load();

await vi.runAllTimersAsync();
await loadPromise;
} finally {
authCreateSpy.mockRestore();
}

expect(mountSpy).toHaveBeenCalledTimes(2);
expect(mockAuthService.handleUnauthenticatedDevBrowser).toHaveBeenCalledTimes(1);
expect(mockClientFetch).toHaveBeenCalledTimes(2);
});

it('surfaces network errors after exhausting retries', async () => {
vi.useFakeTimers();

const mockAuthService = createMockAuthService();
const authCreateSpy = vi
.spyOn(AuthCookieService, 'create')
.mockResolvedValue(mockAuthService as unknown as AuthCookieService);

const networkError = new ClerkRuntimeError('Network failure', { code: 'network_error' });
const mountSpy = vi.fn<NonNullable<typeof Clerk.mountComponentRenderer>>().mockImplementation(() => {
throw networkError;
});

Clerk.mountComponentRenderer = mountSpy;
mockClientFetch.mockClear();

const errorSpy = vi.spyOn(errorsModule, 'clerkErrorInitFailed');
const sut = new Clerk(productionPublishableKey);

try {
const loadPromise = sut.load();

// Attach rejection handler before advancing timers to avoid unhandled rejection
const expectation = expect(loadPromise).rejects.toThrow(/Something went wrong initializing Clerk/);
await vi.runAllTimersAsync();

const err = await loadPromise.catch(e => e);
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toMatch(/Something went wrong initializing Clerk/);
const cause = (err as Error).cause as any;
expect(cause).toBeDefined();
expect(cause.code).toBe('network_error');
expect(cause.clerkRuntimeError).toBe(true);

await expectation;

expect(mountSpy).toHaveBeenCalledTimes(2);
expect(mockClientFetch).toHaveBeenCalledTimes(2);
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenLastCalledWith(networkError);
expect(mockAuthService.handleUnauthenticatedDevBrowser).not.toHaveBeenCalled();
} finally {
authCreateSpy.mockRestore();
errorSpy.mockRestore();
}
});

it('retries when environment fetch fails and no cached snapshot exists', async () => {
vi.useFakeTimers();

const mockAuthService = createMockAuthService();
const authCreateSpy = vi
.spyOn(AuthCookieService, 'create')
.mockResolvedValue(mockAuthService as unknown as AuthCookieService);

const localStorageSpy = vi.spyOn(localStorageModule.SafeLocalStorage, 'getItem').mockReturnValue(null);

mockEnvironmentFetch.mockReset().mockRejectedValueOnce(new Error('Network error')).mockResolvedValue({});

const componentControls = createMockComponentControls();
const mountSpy = vi.fn<NonNullable<typeof Clerk.mountComponentRenderer>>().mockReturnValue(componentControls);
Clerk.mountComponentRenderer = mountSpy;
mockClientFetch.mockClear();

const sut = new Clerk(productionPublishableKey);

try {
const loadPromise = sut.load();
await vi.runAllTimersAsync();
await loadPromise;
} finally {
authCreateSpy.mockRestore();
localStorageSpy.mockRestore();
}

expect(mockEnvironmentFetch).toHaveBeenCalledTimes(2);
expect(mockClientFetch).toHaveBeenCalledTimes(2);
});

it('uses cached environment when fetch fails but cache exists', async () => {
const cachedEnv = {
userSettings: { signUp: { captcha_enabled: false } },
displayConfig: {},
};
const localStorageSpy = vi.spyOn(localStorageModule.SafeLocalStorage, 'getItem').mockReturnValue(cachedEnv);

mockEnvironmentFetch.mockReset().mockRejectedValue(new Error('Network error'));
mockClientFetch.mockClear().mockResolvedValue({ signedInSessions: [] });

const sut = new Clerk(productionPublishableKey);

try {
await sut.load();
} finally {
localStorageSpy.mockRestore();
}

// Should only fetch once since cache is used as fallback (no retry needed)
expect(mockEnvironmentFetch).toHaveBeenCalledTimes(1);
expect(mockClientFetch).toHaveBeenCalledTimes(1);
});
});

describe('.setActive', () => {
describe('with `active` session status', () => {
const mockSession = {
Expand Down
Loading
Loading