Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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));
}
});
Comment on lines +2575 to +2588
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t swallow the environment fetch failure when no cache exists.
When the very first /environment request fails (e.g., transient network blip on a cold start), this catch clause increments the degraded counter, finds no snapshot in localStorage, and then resolves. Because the error never bubbles out, withRetry treats the attempt as a success and skips the second pass, so load() completes with this.environment still holding the empty singleton. That leaves the UI without required config until the user hard refreshes. Please rethrow when we can’t hydrate from cache so the retry wrapper can actually run:

-        .catch(() => {
+        .catch(error => {
           ++initializationDegradedCounter;
           const environmentSnapshot = SafeLocalStorage.getItem<EnvironmentJSONSnapshot | null>(
             CLERK_ENVIRONMENT_STORAGE_ENTRY,
             null,
           );

           if (environmentSnapshot) {
             this.updateEnvironment(new Environment(environmentSnapshot));
+            return;
           }
+
+          throw error;
         });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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));
}
});
const initEnvironmentPromise = Environment.getInstance()
.fetch({ touch: shouldTouchEnv })
.then(res => this.updateEnvironment(res))
.catch(error => {
+initializationDegradedCounter;
const environmentSnapshot = SafeLocalStorage.getItem<EnvironmentJSONSnapshot | null>(
CLERK_ENVIRONMENT_STORAGE_ENTRY,
null,
);
if (environmentSnapshot) {
this.updateEnvironment(new Environment(environmentSnapshot));
return;
}
throw error;
});
🤖 Prompt for AI Agents
In packages/clerk-js/src/core/clerk.ts around lines 2575 to 2588, the catch
block for Environment.getInstance().fetch currently swallows errors when no
cached snapshot exists causing withRetry to think the attempt succeeded; change
the catch to receive the error (catch(err)) and after incrementing
initializationDegradedCounter and attempting to read the local snapshot, if no
snapshot is found rethrow the original error (or wrap and throw it) so the retry
wrapper can run; if a snapshot exists, continue to hydrate environment as before
and do not rethrow.


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