Skip to content

feat(shared): Detect clerk-js loading failure #6261

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
5 changes: 5 additions & 0 deletions .changeset/cold-trees-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': minor
---

Add timeout-based mechanism to detect when clerk-js fails to load and create a minimal fallback Clerk instance to trigger error components.
3 changes: 2 additions & 1 deletion integration/tests/resiliency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,9 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc
await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible();

// Wait for loading to complete and verify final state
// Account for the new 15-second script loading timeout plus buffer for UI updates
await expect(page.getByText('Status: error', { exact: true })).toBeVisible({
timeout: 10_000,
timeout: 16_000,
});
await expect(page.getByText('Clerk is out', { exact: true })).toBeVisible();
await expect(page.getByText('Clerk is ready or degraded (loaded)')).toBeHidden();
Expand Down
108 changes: 90 additions & 18 deletions packages/shared/src/__tests__/loadClerkJsScript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,56 @@ jest.mock('../loadScript');
setClerkJsLoadingErrorPackageName('@clerk/clerk-react');
const jsPackageMajorVersion = getMajorVersion(JS_PACKAGE_VERSION);

const mockClerk = {
status: 'ready',
loaded: true,
load: jest.fn(),
};

describe('loadClerkJsScript(options)', () => {
const mockPublishableKey = 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk';

beforeEach(() => {
jest.clearAllMocks();
(loadScript as jest.Mock).mockResolvedValue(undefined);
document.querySelector = jest.fn().mockReturnValue(null);

(window as any).Clerk = undefined;

jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

test('throws error when publishableKey is missing', async () => {
await expect(() => loadClerkJsScript({} as any)).rejects.toThrow(
await expect(loadClerkJsScript({} as any)).rejects.toThrow(
'@clerk/clerk-react: Missing publishableKey. You can get your key at https://dashboard.clerk.com/last-active?path=api-keys.',
);
});

test('loads script when no existing script is found', async () => {
await loadClerkJsScript({ publishableKey: mockPublishableKey });
test('returns null immediately when Clerk is already loaded', async () => {
(window as any).Clerk = mockClerk;

const result = await loadClerkJsScript({ publishableKey: mockPublishableKey });
expect(result).toBeNull();
expect(loadScript).not.toHaveBeenCalled();
});

test('loads script and waits for Clerk to be available', async () => {
const loadPromise = loadClerkJsScript({ publishableKey: mockPublishableKey });

// Simulate Clerk becoming available after 250ms
setTimeout(() => {
(window as any).Clerk = mockClerk;
}, 250);

// Advance timers to allow polling to detect Clerk
jest.advanceTimersByTime(300);

const result = await loadPromise;
expect(result).toBeNull();
expect(loadScript).toHaveBeenCalledWith(
expect.stringContaining(
`https://foo-bar-13.clerk.accounts.dev/npm/@clerk/clerk-js@${jsPackageMajorVersion}/dist/clerk.browser.js`,
Expand All @@ -42,34 +74,74 @@ describe('loadClerkJsScript(options)', () => {
);
});

test('uses existing script when found', async () => {
const mockExistingScript = document.createElement('script');
document.querySelector = jest.fn().mockReturnValue(mockExistingScript);
test('times out and rejects when Clerk does not load', async () => {
let rejectedWith: any;

const loadPromise = loadClerkJsScript({ publishableKey: mockPublishableKey });
mockExistingScript.dispatchEvent(new Event('load'));
const loadPromise = loadClerkJsScript({ publishableKey: mockPublishableKey, scriptLoadTimeout: 1000 });

await expect(loadPromise).resolves.toBe(mockExistingScript);
expect(loadScript).not.toHaveBeenCalled();
try {
jest.advanceTimersByTime(1000);
await loadPromise;
} catch (error) {
rejectedWith = error;
}

expect(rejectedWith).toBeInstanceOf(Error);
expect(rejectedWith.message).toBe('Clerk: Failed to load Clerk');
expect((window as any).Clerk).toBeUndefined();
});

test('rejects when existing script fails to load', async () => {
test('waits for existing script with timeout', async () => {
const mockExistingScript = document.createElement('script');
document.querySelector = jest.fn().mockReturnValue(mockExistingScript);

const loadPromise = loadClerkJsScript({ publishableKey: mockPublishableKey });
mockExistingScript.dispatchEvent(new Event('error'));

await expect(loadPromise).rejects.toBe('Clerk: Failed to load Clerk');
// Simulate Clerk becoming available after 250ms
setTimeout(() => {
(window as any).Clerk = mockClerk;
}, 250);

// Advance timers to allow polling to detect Clerk
jest.advanceTimersByTime(300);

const result = await loadPromise;
expect(result).toBeNull();
expect(loadScript).not.toHaveBeenCalled();
});

test('throws error when loadScript fails', async () => {
(loadScript as jest.Mock).mockRejectedValue(new Error('Script load failed'));
test('handles race condition when Clerk loads just as timeout fires', async () => {
const loadPromise = loadClerkJsScript({ publishableKey: mockPublishableKey, scriptLoadTimeout: 1000 });

await expect(loadClerkJsScript({ publishableKey: mockPublishableKey })).rejects.toThrow(
'Clerk: Failed to load Clerk',
);
setTimeout(() => {
(window as any).Clerk = mockClerk;
}, 999);

jest.advanceTimersByTime(1000);

const result = await loadPromise;
expect(result).toBeNull();
expect((window as any).Clerk).toBe(mockClerk);
});

test('validates Clerk is properly loaded with required methods', async () => {
const loadPromise = loadClerkJsScript({ publishableKey: mockPublishableKey });

setTimeout(() => {
(window as any).Clerk = { status: 'ready' };
}, 100);

jest.advanceTimersByTime(15000);

try {
await loadPromise;
fail('Should have thrown error');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('Clerk: Failed to load Clerk');
// The malformed Clerk object should still be there since it was set
expect((window as any).Clerk).toEqual({ status: 'ready' });
}
});
});

Expand Down
141 changes: 121 additions & 20 deletions packages/shared/src/loadClerkJsScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ const errorThrower = buildErrorThrower({ packageName: '@clerk/shared' });
/**
* Sets the package name for error messages during ClerkJS script loading.
*
* @param packageName - The name of the package to use in error messages (e.g., '@clerk/clerk-react').
* @example
* ```typescript
* setClerkJsLoadingErrorPackageName('@clerk/clerk-react');
* ```
*/
export function setClerkJsLoadingErrorPackageName(packageName: string) {
errorThrower.setPackageName({ packageName });
Expand All @@ -32,58 +35,147 @@ type LoadClerkJsScriptOptions = Without<ClerkOptions, 'isSatellite'> & {
proxyUrl?: string;
domain?: string;
nonce?: string;
/**
* Timeout in milliseconds to wait for clerk-js to load before considering it failed.
*
* @default 15000 (15 seconds)
*/
scriptLoadTimeout?: number;
};

/**
* Hotloads the Clerk JS script.
* Validates that window.Clerk exists and is properly initialized.
* This ensures we don't have false positives where the script loads but Clerk is malformed.
*
* Checks for an existing Clerk JS script. If found, it returns a promise
* that resolves when the script loads. If not found, it uses the provided options to
* build the Clerk JS script URL and load the script.
* @returns `true` if window.Clerk exists and has the expected structure with a load method.
*/
function isClerkProperlyLoaded(): boolean {
if (typeof window === 'undefined' || !(window as any).Clerk) {
return false;
}

// Basic validation that window.Clerk has the expected structure
const clerk = (window as any).Clerk;
return typeof clerk === 'object' && typeof clerk.load === 'function';
}

/**
* Waits for Clerk to be properly loaded with a timeout mechanism.
* Uses polling to check if Clerk becomes available within the specified timeout.
*
* @param timeoutMs - Maximum time to wait in milliseconds.
* @returns Promise that resolves with null if Clerk loads successfully, or rejects with an error if timeout is reached.
*/
function waitForClerkWithTimeout(timeoutMs: number): Promise<HTMLScriptElement | null> {
return new Promise((resolve, reject) => {
let resolved = false;

const cleanup = (timeoutId: ReturnType<typeof setTimeout>, pollInterval: ReturnType<typeof setInterval>) => {
clearTimeout(timeoutId);
clearInterval(pollInterval);
};

const checkAndResolve = () => {
if (resolved) return;

if (isClerkProperlyLoaded()) {
resolved = true;
cleanup(timeoutId, pollInterval);
resolve(null);
}
};

const handleTimeout = () => {
if (resolved) return;

resolved = true;
cleanup(timeoutId, pollInterval);

if (!isClerkProperlyLoaded()) {
reject(new Error(FAILED_TO_LOAD_ERROR));
} else {
resolve(null);
}
};

const timeoutId = setTimeout(handleTimeout, timeoutMs);

checkAndResolve();

const pollInterval = setInterval(() => {
if (resolved) {
clearInterval(pollInterval);
return;
}
checkAndResolve();
}, 100);
});
}

/**
* Hotloads the Clerk JS script with robust failure detection.
*
* Uses a timeout-based approach to ensure absolute certainty about load success/failure.
* If the script fails to load within the timeout period, or loads but doesn't create
* a proper Clerk instance, the promise rejects with an error.
*
* @param opts - The options used to build the Clerk JS script URL and load the script.
* Must include a `publishableKey` if no existing script is found.
* @returns Promise that resolves with null if Clerk loads successfully, or rejects with an error.
*
* @example
* loadClerkJsScript({ publishableKey: 'pk_' });
* ```typescript
* try {
* await loadClerkJsScript({ publishableKey: 'pk_test_...' });
* console.log('Clerk loaded successfully');
* } catch (error) {
* console.error('Failed to load Clerk:', error.message);
* }
* ```
*/
const loadClerkJsScript = async (opts?: LoadClerkJsScriptOptions) => {
const loadClerkJsScript = async (opts?: LoadClerkJsScriptOptions): Promise<HTMLScriptElement | null> => {
const timeout = opts?.scriptLoadTimeout ?? 15000;

if (isClerkProperlyLoaded()) {
return null;
}

const existingScript = document.querySelector<HTMLScriptElement>('script[data-clerk-js-script]');

if (existingScript) {
return new Promise((resolve, reject) => {
existingScript.addEventListener('load', () => {
resolve(existingScript);
});

existingScript.addEventListener('error', () => {
reject(FAILED_TO_LOAD_ERROR);
});
});
return waitForClerkWithTimeout(timeout);
}

if (!opts?.publishableKey) {
errorThrower.throwMissingPublishableKeyError();
return;
return null;
}

return loadScript(clerkJsScriptUrl(opts), {
const loadPromise = waitForClerkWithTimeout(timeout);

loadScript(clerkJsScriptUrl(opts), {
async: true,
crossOrigin: 'anonymous',
nonce: opts.nonce,
beforeLoad: applyClerkJsScriptAttributes(opts),
}).catch(() => {
throw new Error(FAILED_TO_LOAD_ERROR);
});

return loadPromise;
};

/**
* Generates a Clerk JS script URL.
* Generates a Clerk JS script URL based on the provided options.
*
* @param opts - The options to use when building the Clerk JS script URL.
* @returns The complete URL to the Clerk JS script.
*
* @example
* clerkJsScriptUrl({ publishableKey: 'pk_' });
* ```typescript
* const url = clerkJsScriptUrl({ publishableKey: 'pk_test_...' });
* // Returns: "https://example.clerk.accounts.dev/npm/@clerk/clerk-js@5/dist/clerk.browser.js"
* ```
*/
const clerkJsScriptUrl = (opts: LoadClerkJsScriptOptions) => {
const { clerkJSUrl, clerkJSVariant, clerkJSVersion, proxyUrl, domain, publishableKey } = opts;
Expand All @@ -107,7 +199,10 @@ const clerkJsScriptUrl = (opts: LoadClerkJsScriptOptions) => {
};

/**
* Builds an object of Clerk JS script attributes.
* Builds an object of Clerk JS script attributes based on the provided options.
*
* @param options - The options containing the values for script attributes.
* @returns An object containing data attributes to be applied to the script element.
*/
const buildClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => {
const obj: Record<string, string> = {};
Expand All @@ -131,6 +226,12 @@ const buildClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => {
return obj;
};

/**
* Returns a function that applies Clerk JS script attributes to a script element.
*
* @param options - The options containing the values for script attributes.
* @returns A function that accepts a script element and applies the attributes to it.
*/
const applyClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => (script: HTMLScriptElement) => {
const attributes = buildClerkJsScriptAttributes(options);
for (const attribute in attributes) {
Expand Down
Loading
Loading