Skip to content
126 changes: 125 additions & 1 deletion packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EmailLinkErrorCodeStatus } from '@clerk/shared/error';
import { ClerkRuntimeError, EmailLinkErrorCodeStatus } from '@clerk/shared/error';
import type {
ActiveSessionResource,
PendingSessionResource,
Expand All @@ -13,8 +13,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test,
import { mockJwt } from '@/test/core-fixtures';

import { mockNativeRuntime } from '../../test/utils';
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 @@ -157,6 +159,128 @@ 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();

await vi.runAllTimersAsync();

try {
await loadPromise;
throw new Error('Expected load to throw');
} catch (err) {
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);
}

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();
}
});
});

describe('.setActive', () => {
describe('with `active` session status', () => {
const mockSession = {
Expand Down
176 changes: 87 additions & 89 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ import { assertNoLegacyProp } from '../utils/assertNoLegacyProp';
import { CLERK_ENVIRONMENT_STORAGE_ENTRY, SafeLocalStorage } from '../utils/localStorage';
import { memoizeListenerCallback } from '../utils/memoizeStateListenerCallback';
import { RedirectUrls } from '../utils/redirectUrls';
import { withRetry } from '../utils/retry';
import { AuthCookieService } from './auth/AuthCookieService';
import { CaptchaHeartbeat } from './auth/CaptchaHeartbeat';
import { CLERK_SATELLITE_URL, CLERK_SUFFIXED_COOKIES, CLERK_SYNCED, ERROR_CODES } from './constants';
Expand Down Expand Up @@ -2570,112 +2571,109 @@ export class Clerk implements ClerkInterface {

let initializationDegradedCounter = 0;

let retries = 0;
while (retries < 2) {
retries++;
const initializeClerk = async (): Promise<void> => {
const initEnvironmentPromise = Environment.getInstance()
.fetch({ touch: shouldTouchEnv })
.then(res => this.updateEnvironment(res))
.catch(() => {
++initializationDegradedCounter;
const environmentSnapshot = SafeLocalStorage.getItem<EnvironmentJSONSnapshot | null>(
CLERK_ENVIRONMENT_STORAGE_ENTRY,
null,
);

try {
const initEnvironmentPromise = Environment.getInstance()
.fetch({ touch: shouldTouchEnv })
.then(res => this.updateEnvironment(res))
.catch(() => {
++initializationDegradedCounter;
const environmentSnapshot = SafeLocalStorage.getItem<EnvironmentJSONSnapshot | null>(
CLERK_ENVIRONMENT_STORAGE_ENTRY,
null,
);
if (environmentSnapshot) {
this.updateEnvironment(new Environment(environmentSnapshot));
}
});

if (environmentSnapshot) {
this.updateEnvironment(new Environment(environmentSnapshot));
const initClient = async () => {
return Client.getOrCreateInstance()
.fetch()
.then(res => this.updateClient(res))
.catch(async e => {
/**
* Only handle non 4xx errors, like 5xx errors and network errors.
*/
if (is4xxError(e)) {
throw e;
}
});

const initClient = async () => {
return Client.getOrCreateInstance()
.fetch()
.then(res => this.updateClient(res))
.catch(async e => {
/**
* Only handle non 4xx errors, like 5xx errors and network errors.
*/
if (is4xxError(e)) {
// bubble it up
throw e;
}

++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();

// 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();
});

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

const initComponents = () => {
if (Clerk.mountComponentRenderer && !this.#componentControls) {
this.#componentControls = Clerk.mountComponentRenderer(
this,
this.environment as Environment,
this.#options,
);
}
};
++initializationDegradedCounter;

const [, clientResult] = await allSettled([initEnvironmentPromise, initClient()]);
const jwtInCookie = this.#authService?.getSessionCookie();
const localClient = createClientFromJwt(jwtInCookie);

if (clientResult.status === 'rejected') {
const e = clientResult.reason;
this.updateClient(localClient);

if (isError(e, 'requires_captcha')) {
initComponents();
await initClient();
} else {
throw e;
}
}
/**
* 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();

this.#authService?.setClientUatCookieForDevelopmentInstances();
await this.session
?.getToken({ skipCache: true })
.catch(() => null)
.finally(() => {
this.#authService?.startPollingForToken();
});

if (await this.#redirectFAPIInitiatedFlow()) {
return;
return null;
});
};

const initComponents = () => {
if (Clerk.mountComponentRenderer && !this.#componentControls) {
this.#componentControls = Clerk.mountComponentRenderer(this, this.environment as Environment, this.#options);
}
};

initComponents();
const [, clientResult] = await allSettled([initEnvironmentPromise, initClient()]);

break;
} catch (err) {
if (isError(err, 'dev_browser_unauthenticated')) {
await this.#authService.handleUnauthenticatedDevBrowser();
} else if (!isValidBrowserOnline()) {
console.warn(err);
return;
if (clientResult.status === 'rejected') {
const e = clientResult.reason;

if (isError(e, 'requires_captcha')) {
initComponents();
await initClient();
} else {
throw err;
throw e;
}
}

if (retries >= 2) {
clerkErrorInitFailed();
this.#authService?.setClientUatCookieForDevelopmentInstances();

if (await this.#redirectFAPIInitiatedFlow()) {
return;
}

initComponents();
};

try {
await withRetry(initializeClerk, {
jitter: true,
maxAttempts: 2,
shouldRetry: async error => {
if (!isValidBrowserOnline()) {
console.warn(error);
return false;
}

const isDevBrowserUnauthenticated = isError(error as any, 'dev_browser_unauthenticated');
const isNetworkError = isClerkRuntimeError(error) && error.code === 'network_error';

if (isDevBrowserUnauthenticated && this.#authService) {
await this.#authService.handleUnauthenticatedDevBrowser();
return true;
}

return isNetworkError;
},
});
} catch (err) {
clerkErrorInitFailed(err);
}

this.#captchaHeartbeat = new CaptchaHeartbeat(this);
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export function clerkNetworkError(url: string, e: Error): never {
throw new Error(`${errorPrefix} Network error at "${url}" - ${e}. Please try again.`);
}

export function clerkErrorInitFailed(): never {
throw new Error(`${errorPrefix} Something went wrong initializing Clerk.`);
export function clerkErrorInitFailed(error?: unknown): never {
throw new Error(`${errorPrefix} Something went wrong initializing Clerk.`, { cause: error });
}

export function clerkErrorDevInitFailed(msg = ''): never {
Expand Down
Loading
Loading