Skip to content

Commit 959d63d

Browse files
authored
feat(shared): Detect clerk-js loading failure (#6261)
1 parent e46ac82 commit 959d63d

File tree

5 files changed

+265
-46
lines changed

5 files changed

+265
-46
lines changed

.changeset/cold-trees-hammer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/shared': minor
3+
---
4+
5+
Add timeout-based mechanism to detect when clerk-js fails to load and set the status to `error` on isomorphicClerk

integration/tests/resiliency.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,9 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc
184184
await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible();
185185

186186
// Wait for loading to complete and verify final state
187+
// Account for the new 15-second script loading timeout plus buffer for UI updates
187188
await expect(page.getByText('Status: error', { exact: true })).toBeVisible({
188-
timeout: 10_000,
189+
timeout: 16_000,
189190
});
190191
await expect(page.getByText('Clerk is out', { exact: true })).toBeVisible();
191192
await expect(page.getByText('Clerk is ready or degraded (loaded)')).toBeHidden();

packages/shared/src/__tests__/loadClerkJsScript.test.ts

Lines changed: 90 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,56 @@ jest.mock('../loadScript');
1212
setClerkJsLoadingErrorPackageName('@clerk/clerk-react');
1313
const jsPackageMajorVersion = getMajorVersion(JS_PACKAGE_VERSION);
1414

15+
const mockClerk = {
16+
status: 'ready',
17+
loaded: true,
18+
load: jest.fn(),
19+
};
20+
1521
describe('loadClerkJsScript(options)', () => {
1622
const mockPublishableKey = 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk';
1723

1824
beforeEach(() => {
1925
jest.clearAllMocks();
2026
(loadScript as jest.Mock).mockResolvedValue(undefined);
2127
document.querySelector = jest.fn().mockReturnValue(null);
28+
29+
(window as any).Clerk = undefined;
30+
31+
jest.useFakeTimers();
32+
});
33+
34+
afterEach(() => {
35+
jest.useRealTimers();
2236
});
2337

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

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

47+
const result = await loadClerkJsScript({ publishableKey: mockPublishableKey });
48+
expect(result).toBeNull();
49+
expect(loadScript).not.toHaveBeenCalled();
50+
});
51+
52+
test('loads script and waits for Clerk to be available', async () => {
53+
const loadPromise = loadClerkJsScript({ publishableKey: mockPublishableKey });
54+
55+
// Simulate Clerk becoming available after 250ms
56+
setTimeout(() => {
57+
(window as any).Clerk = mockClerk;
58+
}, 250);
59+
60+
// Advance timers to allow polling to detect Clerk
61+
jest.advanceTimersByTime(300);
62+
63+
const result = await loadPromise;
64+
expect(result).toBeNull();
3365
expect(loadScript).toHaveBeenCalledWith(
3466
expect.stringContaining(
3567
`https://foo-bar-13.clerk.accounts.dev/npm/@clerk/clerk-js@${jsPackageMajorVersion}/dist/clerk.browser.js`,
@@ -42,34 +74,74 @@ describe('loadClerkJsScript(options)', () => {
4274
);
4375
});
4476

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

49-
const loadPromise = loadClerkJsScript({ publishableKey: mockPublishableKey });
50-
mockExistingScript.dispatchEvent(new Event('load'));
80+
const loadPromise = loadClerkJsScript({ publishableKey: mockPublishableKey, scriptLoadTimeout: 1000 });
5181

52-
await expect(loadPromise).resolves.toBe(mockExistingScript);
53-
expect(loadScript).not.toHaveBeenCalled();
82+
try {
83+
jest.advanceTimersByTime(1000);
84+
await loadPromise;
85+
} catch (error) {
86+
rejectedWith = error;
87+
}
88+
89+
expect(rejectedWith).toBeInstanceOf(Error);
90+
expect(rejectedWith.message).toBe('Clerk: Failed to load Clerk');
91+
expect((window as any).Clerk).toBeUndefined();
5492
});
5593

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

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

63-
await expect(loadPromise).rejects.toBe('Clerk: Failed to load Clerk');
100+
// Simulate Clerk becoming available after 250ms
101+
setTimeout(() => {
102+
(window as any).Clerk = mockClerk;
103+
}, 250);
104+
105+
// Advance timers to allow polling to detect Clerk
106+
jest.advanceTimersByTime(300);
107+
108+
const result = await loadPromise;
109+
expect(result).toBeNull();
64110
expect(loadScript).not.toHaveBeenCalled();
65111
});
66112

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

70-
await expect(loadClerkJsScript({ publishableKey: mockPublishableKey })).rejects.toThrow(
71-
'Clerk: Failed to load Clerk',
72-
);
116+
setTimeout(() => {
117+
(window as any).Clerk = mockClerk;
118+
}, 999);
119+
120+
jest.advanceTimersByTime(1000);
121+
122+
const result = await loadPromise;
123+
expect(result).toBeNull();
124+
expect((window as any).Clerk).toBe(mockClerk);
125+
});
126+
127+
test('validates Clerk is properly loaded with required methods', async () => {
128+
const loadPromise = loadClerkJsScript({ publishableKey: mockPublishableKey });
129+
130+
setTimeout(() => {
131+
(window as any).Clerk = { status: 'ready' };
132+
}, 100);
133+
134+
jest.advanceTimersByTime(15000);
135+
136+
try {
137+
await loadPromise;
138+
fail('Should have thrown error');
139+
} catch (error) {
140+
expect(error).toBeInstanceOf(Error);
141+
expect((error as Error).message).toBe('Clerk: Failed to load Clerk');
142+
// The malformed Clerk object should still be there since it was set
143+
expect((window as any).Clerk).toEqual({ status: 'ready' });
144+
}
73145
});
74146
});
75147

packages/shared/src/loadClerkJsScript.ts

Lines changed: 121 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@ const errorThrower = buildErrorThrower({ packageName: '@clerk/shared' });
1616
/**
1717
* Sets the package name for error messages during ClerkJS script loading.
1818
*
19+
* @param packageName - The name of the package to use in error messages (e.g., '@clerk/clerk-react').
1920
* @example
21+
* ```typescript
2022
* setClerkJsLoadingErrorPackageName('@clerk/clerk-react');
23+
* ```
2124
*/
2225
export function setClerkJsLoadingErrorPackageName(packageName: string) {
2326
errorThrower.setPackageName({ packageName });
@@ -32,58 +35,147 @@ type LoadClerkJsScriptOptions = Without<ClerkOptions, 'isSatellite'> & {
3235
proxyUrl?: string;
3336
domain?: string;
3437
nonce?: string;
38+
/**
39+
* Timeout in milliseconds to wait for clerk-js to load before considering it failed.
40+
*
41+
* @default 15000 (15 seconds)
42+
*/
43+
scriptLoadTimeout?: number;
3544
};
3645

3746
/**
38-
* Hotloads the Clerk JS script.
47+
* Validates that window.Clerk exists and is properly initialized.
48+
* This ensures we don't have false positives where the script loads but Clerk is malformed.
3949
*
40-
* Checks for an existing Clerk JS script. If found, it returns a promise
41-
* that resolves when the script loads. If not found, it uses the provided options to
42-
* build the Clerk JS script URL and load the script.
50+
* @returns `true` if window.Clerk exists and has the expected structure with a load method.
51+
*/
52+
function isClerkProperlyLoaded(): boolean {
53+
if (typeof window === 'undefined' || !(window as any).Clerk) {
54+
return false;
55+
}
56+
57+
// Basic validation that window.Clerk has the expected structure
58+
const clerk = (window as any).Clerk;
59+
return typeof clerk === 'object' && typeof clerk.load === 'function';
60+
}
61+
62+
/**
63+
* Waits for Clerk to be properly loaded with a timeout mechanism.
64+
* Uses polling to check if Clerk becomes available within the specified timeout.
65+
*
66+
* @param timeoutMs - Maximum time to wait in milliseconds.
67+
* @returns Promise that resolves with null if Clerk loads successfully, or rejects with an error if timeout is reached.
68+
*/
69+
function waitForClerkWithTimeout(timeoutMs: number): Promise<HTMLScriptElement | null> {
70+
return new Promise((resolve, reject) => {
71+
let resolved = false;
72+
73+
const cleanup = (timeoutId: ReturnType<typeof setTimeout>, pollInterval: ReturnType<typeof setInterval>) => {
74+
clearTimeout(timeoutId);
75+
clearInterval(pollInterval);
76+
};
77+
78+
const checkAndResolve = () => {
79+
if (resolved) return;
80+
81+
if (isClerkProperlyLoaded()) {
82+
resolved = true;
83+
cleanup(timeoutId, pollInterval);
84+
resolve(null);
85+
}
86+
};
87+
88+
const handleTimeout = () => {
89+
if (resolved) return;
90+
91+
resolved = true;
92+
cleanup(timeoutId, pollInterval);
93+
94+
if (!isClerkProperlyLoaded()) {
95+
reject(new Error(FAILED_TO_LOAD_ERROR));
96+
} else {
97+
resolve(null);
98+
}
99+
};
100+
101+
const timeoutId = setTimeout(handleTimeout, timeoutMs);
102+
103+
checkAndResolve();
104+
105+
const pollInterval = setInterval(() => {
106+
if (resolved) {
107+
clearInterval(pollInterval);
108+
return;
109+
}
110+
checkAndResolve();
111+
}, 100);
112+
});
113+
}
114+
115+
/**
116+
* Hotloads the Clerk JS script with robust failure detection.
117+
*
118+
* Uses a timeout-based approach to ensure absolute certainty about load success/failure.
119+
* If the script fails to load within the timeout period, or loads but doesn't create
120+
* a proper Clerk instance, the promise rejects with an error.
43121
*
44122
* @param opts - The options used to build the Clerk JS script URL and load the script.
45123
* Must include a `publishableKey` if no existing script is found.
124+
* @returns Promise that resolves with null if Clerk loads successfully, or rejects with an error.
46125
*
47126
* @example
48-
* loadClerkJsScript({ publishableKey: 'pk_' });
127+
* ```typescript
128+
* try {
129+
* await loadClerkJsScript({ publishableKey: 'pk_test_...' });
130+
* console.log('Clerk loaded successfully');
131+
* } catch (error) {
132+
* console.error('Failed to load Clerk:', error.message);
133+
* }
134+
* ```
49135
*/
50-
const loadClerkJsScript = async (opts?: LoadClerkJsScriptOptions) => {
136+
const loadClerkJsScript = async (opts?: LoadClerkJsScriptOptions): Promise<HTMLScriptElement | null> => {
137+
const timeout = opts?.scriptLoadTimeout ?? 15000;
138+
139+
if (isClerkProperlyLoaded()) {
140+
return null;
141+
}
142+
51143
const existingScript = document.querySelector<HTMLScriptElement>('script[data-clerk-js-script]');
52144

53145
if (existingScript) {
54-
return new Promise((resolve, reject) => {
55-
existingScript.addEventListener('load', () => {
56-
resolve(existingScript);
57-
});
58-
59-
existingScript.addEventListener('error', () => {
60-
reject(FAILED_TO_LOAD_ERROR);
61-
});
62-
});
146+
return waitForClerkWithTimeout(timeout);
63147
}
64148

65149
if (!opts?.publishableKey) {
66150
errorThrower.throwMissingPublishableKeyError();
67-
return;
151+
return null;
68152
}
69153

70-
return loadScript(clerkJsScriptUrl(opts), {
154+
const loadPromise = waitForClerkWithTimeout(timeout);
155+
156+
loadScript(clerkJsScriptUrl(opts), {
71157
async: true,
72158
crossOrigin: 'anonymous',
73159
nonce: opts.nonce,
74160
beforeLoad: applyClerkJsScriptAttributes(opts),
75161
}).catch(() => {
76162
throw new Error(FAILED_TO_LOAD_ERROR);
77163
});
164+
165+
return loadPromise;
78166
};
79167

80168
/**
81-
* Generates a Clerk JS script URL.
169+
* Generates a Clerk JS script URL based on the provided options.
82170
*
83171
* @param opts - The options to use when building the Clerk JS script URL.
172+
* @returns The complete URL to the Clerk JS script.
84173
*
85174
* @example
86-
* clerkJsScriptUrl({ publishableKey: 'pk_' });
175+
* ```typescript
176+
* const url = clerkJsScriptUrl({ publishableKey: 'pk_test_...' });
177+
* // Returns: "https://example.clerk.accounts.dev/npm/@clerk/clerk-js@5/dist/clerk.browser.js"
178+
* ```
87179
*/
88180
const clerkJsScriptUrl = (opts: LoadClerkJsScriptOptions) => {
89181
const { clerkJSUrl, clerkJSVariant, clerkJSVersion, proxyUrl, domain, publishableKey } = opts;
@@ -107,7 +199,10 @@ const clerkJsScriptUrl = (opts: LoadClerkJsScriptOptions) => {
107199
};
108200

109201
/**
110-
* Builds an object of Clerk JS script attributes.
202+
* Builds an object of Clerk JS script attributes based on the provided options.
203+
*
204+
* @param options - The options containing the values for script attributes.
205+
* @returns An object containing data attributes to be applied to the script element.
111206
*/
112207
const buildClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => {
113208
const obj: Record<string, string> = {};
@@ -131,6 +226,12 @@ const buildClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => {
131226
return obj;
132227
};
133228

229+
/**
230+
* Returns a function that applies Clerk JS script attributes to a script element.
231+
*
232+
* @param options - The options containing the values for script attributes.
233+
* @returns A function that accepts a script element and applies the attributes to it.
234+
*/
134235
const applyClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => (script: HTMLScriptElement) => {
135236
const attributes = buildClerkJsScriptAttributes(options);
136237
for (const attribute in attributes) {

0 commit comments

Comments
 (0)