Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': minor
---

Add timeout-based mechanism to detect when clerk-js fails to load and set the status to `error` on isomorphicClerk
3 changes: 2 additions & 1 deletion integration/tests/resiliency.test.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': minor
---

Add timeout-based mechanism to detect when clerk-js fails to load and set the status to `error` on isomorphicClerk
3 changes: 2 additions & 1 deletion integration/tests/resiliency.test.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': minor
---

Add timeout-based mechanism to detect when clerk-js fails to load and set the status to `error` on isomorphicClerk
3 changes: 2 additions & 1 deletion integration/tests/resiliency.test.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': minor
---

Add timeout-based mechanism to detect when clerk-js fails to load and set the status to `error` on isomorphicClerk
3 changes: 2 additions & 1 deletion integration/tests/resiliency.test.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': minor
---

Add timeout-based mechanism to detect when clerk-js fails to load and set the status to `error` on isomorphicClerk
3 changes: 2 additions & 1 deletion integration/tests/resiliency.test.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': minor
---

Add timeout-based mechanism to detect when clerk-js fails to load and set the status to `error` on isomorphicClerk
3 changes: 2 additions & 1 deletion integration/tests/resiliency.test.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': minor
---

Add timeout-based mechanism to detect when clerk-js fails to load and set the status to `error` on isomorphicClerk
3 changes: 2 additions & 1 deletion integration/tests/resiliency.test.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': minor
---

Add timeout-based mechanism to detect when clerk-js fails to load and set the status to `error` on isomorphicClerk
3 changes: 2 additions & 1 deletion integration/tests/resiliency.test.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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