Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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