From 9cae993837df0ad9f97614b1dc2b5878c9566b24 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 25 Sep 2025 23:27:49 -0500 Subject: [PATCH 1/7] Intelligent retries for existing clerk-js script --- packages/shared/src/loadClerkJsScript.ts | 73 ++++++++++++++++++++---- 1 file changed, 62 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/loadClerkJsScript.ts b/packages/shared/src/loadClerkJsScript.ts index 68f81af1778..5fb870709ac 100644 --- a/packages/shared/src/loadClerkJsScript.ts +++ b/packages/shared/src/loadClerkJsScript.ts @@ -59,6 +59,40 @@ function isClerkProperlyLoaded(): boolean { return typeof clerk === 'object' && typeof clerk.load === 'function'; } +/** + * Checks if an existing script has a request error using Performance API. + * + * @param scriptUrl - The URL of the script to check. + * @returns True if the script has failed to load due to a network/HTTP error. + */ +function hasScriptRequestError(scriptUrl: string): boolean { + if (typeof window === 'undefined' || !window.performance) { + return false; + } + + const entries = performance.getEntries() as PerformanceResourceTiming[]; + const scriptEntry = entries.find(entry => entry.name === scriptUrl); + + if (!scriptEntry) { + return false; + } + + // transferSize === 0 with responseEnd === 0 indicates network failure + // transferSize === 0 with responseEnd > 0 might be a 4xx/5xx error or blocked request + if (scriptEntry.transferSize === 0 && scriptEntry.decodedBodySize === 0) { + // If there was no response at all, it's definitely an error + if (scriptEntry.responseEnd === 0) { + return true; + } + // If we got a response but no content, likely an HTTP error (4xx/5xx) + if (scriptEntry.responseEnd > 0 && scriptEntry.responseStart > 0) { + return true; + } + } + + return false; +} + /** * Waits for Clerk to be properly loaded with a timeout mechanism. * Uses polling to check if Clerk becomes available within the specified timeout. @@ -117,11 +151,13 @@ function waitForClerkWithTimeout(timeoutMs: number): Promise('script[data-clerk-js-script]'); - - if (existingScript) { - return waitForClerkWithTimeout(timeout); - } - if (!opts?.publishableKey) { errorThrower.throwMissingPublishableKeyError(); return null; } + const scriptUrl = clerkJsScriptUrl(opts); + const existingScript = document.querySelector('script[data-clerk-js-script]'); + + if (existingScript) { + // Check if the existing script has a request error + const hasError = hasScriptRequestError(scriptUrl); + + if (hasError) { + // Request error detected - remove failed script and initiate loadScript immediately + existingScript.remove(); + } else { + // No request error - wait for timeout, then retry if needed + try { + return await waitForClerkWithTimeout(timeout); + } catch { + // Timeout expired without success - remove script and retry + existingScript.remove(); + } + } + } + const loadPromise = waitForClerkWithTimeout(timeout); - loadScript(clerkJsScriptUrl(opts), { + loadScript(scriptUrl, { async: true, crossOrigin: 'anonymous', nonce: opts.nonce, From 7d0b4fe7514ec6b4550d25fc5f10f85722afa24d Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 25 Sep 2025 23:29:18 -0500 Subject: [PATCH 2/7] remove comments --- packages/shared/src/loadClerkJsScript.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/shared/src/loadClerkJsScript.ts b/packages/shared/src/loadClerkJsScript.ts index 5fb870709ac..845f6a6ba58 100644 --- a/packages/shared/src/loadClerkJsScript.ts +++ b/packages/shared/src/loadClerkJsScript.ts @@ -189,18 +189,12 @@ const loadClerkJsScript = async (opts?: LoadClerkJsScriptOptions): Promise('script[data-clerk-js-script]'); if (existingScript) { - // Check if the existing script has a request error - const hasError = hasScriptRequestError(scriptUrl); - - if (hasError) { - // Request error detected - remove failed script and initiate loadScript immediately + if (hasScriptRequestError(scriptUrl)) { existingScript.remove(); } else { - // No request error - wait for timeout, then retry if needed try { return await waitForClerkWithTimeout(timeout); } catch { - // Timeout expired without success - remove script and retry existingScript.remove(); } } From 6b3efd1639d10fa340f7399f125c24acce0f11f8 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Fri, 26 Sep 2025 15:48:04 -0500 Subject: [PATCH 3/7] tweak heuristic --- packages/shared/src/loadClerkJsScript.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/shared/src/loadClerkJsScript.ts b/packages/shared/src/loadClerkJsScript.ts index 845f6a6ba58..fff2d8b6b0b 100644 --- a/packages/shared/src/loadClerkJsScript.ts +++ b/packages/shared/src/loadClerkJsScript.ts @@ -88,6 +88,10 @@ function hasScriptRequestError(scriptUrl: string): boolean { if (scriptEntry.responseEnd > 0 && scriptEntry.responseStart > 0) { return true; } + + if (scriptEntry.responseStatus === 0) { + return true; + } } return false; From 56a3aefc97b09b5cd0eaa265d0cd6a11f99f15e4 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Mon, 24 Nov 2025 16:11:18 -0600 Subject: [PATCH 4/7] add tests for script load retries --- .../src/app/clerk-status/page.tsx | 35 ++ integration/tests/resiliency.test.ts | 451 ++++++++++++------ packages/shared/src/loadClerkJsScript.ts | 32 +- 3 files changed, 355 insertions(+), 163 deletions(-) create mode 100644 integration/templates/next-app-router/src/app/clerk-status/page.tsx diff --git a/integration/templates/next-app-router/src/app/clerk-status/page.tsx b/integration/templates/next-app-router/src/app/clerk-status/page.tsx new file mode 100644 index 00000000000..2f871185f54 --- /dev/null +++ b/integration/templates/next-app-router/src/app/clerk-status/page.tsx @@ -0,0 +1,35 @@ +'use client'; +import { ClerkLoaded, ClerkLoading, ClerkFailed, ClerkDegraded, useClerk } from '@clerk/nextjs'; + +export default function ClerkStatusPage() { + const { loaded, status } = useClerk(); + + return ( + <> +

Status: {status}

+

{status === 'loading' ? 'Clerk is loading' : null}

+

{status === 'error' ? 'Clerk is out' : null}

+

{status === 'degraded' ? 'Clerk is degraded' : null}

+

{status === 'ready' ? 'Clerk is ready' : null}

+

{status === 'ready' || status === 'degraded' ? 'Clerk is ready or degraded (loaded)' : null}

+

{loaded ? 'Clerk is loaded' : null}

+

{!loaded ? 'Clerk is NOT loaded' : null}

+ + +

(comp) Clerk is degraded

+
+ + +

(comp) Clerk is loaded,(ready or degraded)

+
+ + +

(comp) Something went wrong with Clerk, refresh your page.

+
+ + +

(comp) Waiting for clerk to fail, ready or regraded.

+
+ + ); +} diff --git a/integration/tests/resiliency.test.ts b/integration/tests/resiliency.test.ts index b4d2505ae50..1ce0c5db583 100644 --- a/integration/tests/resiliency.test.ts +++ b/integration/tests/resiliency.test.ts @@ -21,10 +21,6 @@ const make500ClerkResponse = () => ({ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resiliency @generic', ({ app }) => { test.describe.configure({ mode: 'serial' }); - if (app.name.includes('next')) { - test.skip(); - } - let fakeUser: FakeUser; test.beforeAll(async () => { @@ -38,221 +34,364 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc await app.teardown(); }); - test('signed in users can get a fresh session token when Client fails to load', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); + test.describe('loading resiliency', () => { + test('signed in users can get a fresh session token when Client fails to load', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); - await u.po.signIn.goTo(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); - await u.po.expect.toBeSignedIn(); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); - const tokenAfterSignIn = await page.evaluate(() => { - return window.Clerk?.session?.getToken(); - }); + const tokenAfterSignIn = await page.evaluate(() => { + return window.Clerk?.session?.getToken(); + }); - // Simulate developer coming back and client fails to load. - await page.route('**/v1/client?**', route => route.fulfill(make500ClerkResponse())); + // Simulate developer coming back and client fails to load. + await page.route('**/v1/client?**', route => route.fulfill(make500ClerkResponse())); - await page.waitForTimeout(1_000); - await page.reload(); + await page.waitForTimeout(1_000); + await page.reload(); - const waitForClientImmediately = page.waitForResponse( - response => response.url().includes('/client?') && response.status() === 500, - { timeout: 3_000 }, - ); + const waitForClientImmediately = page.waitForResponse( + response => response.url().includes('/client?') && response.status() === 500, + { timeout: 3_000 }, + ); - const waitForTokenImmediately = page.waitForResponse( - response => - response.url().includes('/tokens?') && response.status() === 200 && response.request().method() === 'POST', - { timeout: 3_000 }, - ); + const waitForTokenImmediately = page.waitForResponse( + response => + response.url().includes('/tokens?') && response.status() === 200 && response.request().method() === 'POST', + { timeout: 3_000 }, + ); - await page.waitForLoadState('domcontentloaded'); + await page.waitForLoadState('domcontentloaded'); - await waitForClientImmediately; - await waitForTokenImmediately; + await waitForClientImmediately; + await waitForTokenImmediately; + + // Wait for the client to be loaded. and the internal `getToken({skipCache: true})` to have been completed. + await u.po.clerk.toBeLoaded(); + + // Read the newly refreshed token. + const tokenOnClientOutage = await page.evaluate(() => { + return window.Clerk?.session?.getToken(); + }); - // Wait for the client to be loaded. and the internal `getToken({skipCache: true})` to have been completed. - await u.po.clerk.toBeLoaded(); + expect(tokenOnClientOutage).not.toEqual(tokenAfterSignIn); - // Read the newly refreshed token. - const tokenOnClientOutage = await page.evaluate(() => { - return window.Clerk?.session?.getToken(); + await u.po.expect.toBeSignedIn(); }); - expect(tokenOnClientOutage).not.toEqual(tokenAfterSignIn); + test('resiliency to not break devBrowser - dummy client and is not created on `/client` 4xx errors', async ({ + page, + context, + }) => { + // Simulate "Needs new dev browser, when db jwt exists but does not match the instance". + + const response = { + status: 401, + body: JSON.stringify({ + errors: [ + { + message: '', + long_message: '', + code: 'dev_browser_unauthenticated', + }, + ], + clerk_trace_id: 'some-trace-id', + }), + }; + + const u = createTestUtils({ app, page, context, useTestingToken: false }); + + await page.route('**/v1/client?**', route => { + return route.fulfill(response); + }); - await u.po.expect.toBeSignedIn(); - }); + await page.route('**/v1/environment?**', route => { + return route.fulfill(response); + }); - test('resiliency to not break devBrowser - dummy client and is not created on `/client` 4xx errors', async ({ - page, - context, - }) => { - // Simulate "Needs new dev browser, when db jwt exists but does not match the instance". - - const response = { - status: 401, - body: JSON.stringify({ - errors: [ - { - message: '', - long_message: '', - code: 'dev_browser_unauthenticated', - }, - ], - clerk_trace_id: 'some-trace-id', - }), - }; - - const u = createTestUtils({ app, page, context, useTestingToken: false }); - - await page.route('**/v1/client?**', route => { - return route.fulfill(response); - }); + const waitForClientImmediately = page.waitForResponse( + response => response.url().includes('/client?') && response.status() === 401, + { timeout: 3_000 }, + ); + + const waitForEnvironmentImmediately = page.waitForResponse( + response => response.url().includes('/environment?') && response.status() === 401, + { timeout: 3_000 }, + ); + + await u.page.goToAppHome(); + await page.waitForLoadState('domcontentloaded'); + + await waitForEnvironmentImmediately; + const waitForDevBrowserImmediately = page.waitForResponse( + response => response.url().includes('/dev_browser') && response.status() === 200, + { + timeout: 4_000, + }, + ); + await waitForClientImmediately; + + // To remove specific route handlers + await page.unrouteAll(); + + await waitForDevBrowserImmediately; - await page.route('**/v1/environment?**', route => { - return route.fulfill(response); + await u.po.clerk.toBeLoaded(); }); - const waitForClientImmediately = page.waitForResponse( - response => response.url().includes('/client?') && response.status() === 401, - { timeout: 3_000 }, - ); + test.describe('Clerk.status', () => { + test('normal flow shows correct states and transitions', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/clerk-status'); + + // Initial state checks + await expect(page.getByText('Status: loading', { exact: true })).toBeVisible(); + await expect(page.getByText('Clerk is loading', { exact: true })).toBeVisible(); + await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible(); + await expect(page.getByText('Clerk is out')).toBeHidden(); + await expect(page.getByText('Clerk is degraded')).toBeHidden(); + await expect(page.getByText('(comp) Waiting for clerk to fail, ready or regraded.')).toBeVisible(); + await u.po.clerk.toBeLoading(); + + // Wait for loading to complete and verify final state + await expect(page.getByText('Status: ready', { exact: true })).toBeVisible(); + await u.po.clerk.toBeLoaded(); + await u.po.clerk.toBeReady(); + await expect(page.getByText('Clerk is ready', { exact: true })).toBeVisible(); + await expect(page.getByText('Clerk is ready or degraded (loaded)')).toBeVisible(); + await expect(page.getByText('Clerk is loaded', { exact: true })).toBeVisible(); + await expect(page.getByText('(comp) Clerk is loaded,(ready or degraded)')).toBeVisible(); + + // Verify loading component is no longer visible + await expect(page.getByText('(comp) Waiting for clerk to fail, ready or regraded.')).toBeHidden(); + }); - const waitForEnvironmentImmediately = page.waitForResponse( - response => response.url().includes('/environment?') && response.status() === 401, - { timeout: 3_000 }, - ); + test('clerk-js hotloading failed', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - await page.waitForLoadState('domcontentloaded'); + await page.route('**/clerk.browser.js', route => route.abort()); - await waitForEnvironmentImmediately; - const waitForDevBrowserImmediately = page.waitForResponse( - response => response.url().includes('/dev_browser') && response.status() === 200, - { - timeout: 4_000, - }, - ); - await waitForClientImmediately; + await u.page.goToRelative('/clerk-status'); + + // Initial state checks + await expect(page.getByText('Status: loading', { exact: true })).toBeVisible(); + await expect(page.getByText('Clerk is loading', { exact: true })).toBeVisible(); + await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible(); - // To remove specific route handlers - await page.unrouteAll(); + // 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: 16_000, + }); + await expect(page.getByText('Clerk is out', { exact: true })).toBeVisible(); + await expect(page.getByText('Clerk is ready or degraded (loaded)')).toBeHidden(); + await expect(page.getByText('Clerk is loaded', { exact: true })).toBeHidden(); + await expect(page.getByText('(comp) Clerk is loaded,(ready or degraded)')).toBeHidden(); + await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible(); + + // Verify loading component is no longer visible + await expect(page.getByText('Clerk is loading', { exact: true })).toBeHidden(); + }); + + test('clerk-js client fails and status degraded', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await page.route('**/v1/client?**', route => route.fulfill(make500ClerkResponse())); + + await u.page.goToRelative('/clerk-status'); + + // Initial state checks + await expect(page.getByText('Status: loading', { exact: true })).toBeVisible(); + await expect(page.getByText('Clerk is loading', { exact: true })).toBeVisible(); + await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible(); + + // Wait for loading to complete and verify final state + await expect(page.getByText('Status: degraded', { exact: true })).toBeVisible({ + timeout: 10_000, + }); + await u.po.clerk.toBeDegraded(); + await expect(page.getByText('Clerk is degraded', { exact: true })).toBeVisible(); + await expect(page.getByText('Clerk is ready', { exact: true })).toBeHidden(); + await expect(page.getByText('Clerk is ready or degraded (loaded)')).toBeVisible(); + await expect(page.getByText('Clerk is loaded', { exact: true })).toBeVisible(); + await expect(page.getByText('(comp) Clerk is loaded,(ready or degraded)')).toBeVisible(); + await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeHidden(); + await expect(page.getByText('(comp) Clerk is degraded')).toBeVisible(); + + // Verify loading component is no longer visible + await expect(page.getByText('Clerk is loading', { exact: true })).toBeHidden(); + }); - await waitForDevBrowserImmediately; + test('clerk-js environment fails and status degraded', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); - await u.po.clerk.toBeLoaded(); + await page.route('**/v1/environment?**', route => route.fulfill(make500ClerkResponse())); + + await u.page.goToRelative('/clerk-status'); + + // Initial state checks + await expect(page.getByText('Status: loading', { exact: true })).toBeVisible(); + await expect(page.getByText('Clerk is loading', { exact: true })).toBeVisible(); + await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible(); + await u.po.clerk.toBeLoading(); + + // Wait for loading to complete and verify final state + await expect(page.getByText('Status: degraded', { exact: true })).toBeVisible(); + await u.po.clerk.toBeDegraded(); + await expect(page.getByText('Clerk is degraded', { exact: true })).toBeVisible(); + await expect(page.getByText('Clerk is ready', { exact: true })).toBeHidden(); + await expect(page.getByText('Clerk is ready or degraded (loaded)')).toBeVisible(); + await expect(page.getByText('Clerk is loaded', { exact: true })).toBeVisible(); + await expect(page.getByText('(comp) Clerk is loaded,(ready or degraded)')).toBeVisible(); + await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeHidden(); + await expect(page.getByText('(comp) Clerk is degraded')).toBeVisible(); + + // Verify loading component is no longer visible + await expect(page.getByText('Clerk is loading', { exact: true })).toBeHidden(); + }); + }); }); - test.describe('Clerk.status', () => { - test('normal flow shows correct states and transitions', async ({ page, context }) => { + test.describe('clerk-js script loading', () => { + test('recovers from transient network failure on clerk-js script load', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); + + let requestCount = 0; + await page.route('**/clerk.browser.js', route => { + requestCount++; + // Fail the first request, allow subsequent requests + if (requestCount === 1) { + return route.abort('failed'); + } + return route.continue(); + }); + await u.page.goToRelative('/clerk-status'); - // Initial state checks + // Initial state should show loading await expect(page.getByText('Status: loading', { exact: true })).toBeVisible(); - await expect(page.getByText('Clerk is loading', { exact: true })).toBeVisible(); - await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible(); - await expect(page.getByText('Clerk is out')).toBeHidden(); - await expect(page.getByText('Clerk is degraded')).toBeHidden(); - await expect(page.getByText('(comp) Waiting for clerk to fail, ready or regraded.')).toBeVisible(); - await u.po.clerk.toBeLoading(); - // Wait for loading to complete and verify final state - await expect(page.getByText('Status: ready', { exact: true })).toBeVisible(); + // Wait for Clerk to eventually load after retry + // Account for retry delay + script load time + initialization + await expect(page.getByText('Status: ready', { exact: true })).toBeVisible({ + timeout: 20_000, + }); + await u.po.clerk.toBeLoaded(); - await u.po.clerk.toBeReady(); - await expect(page.getByText('Clerk is ready', { exact: true })).toBeVisible(); - await expect(page.getByText('Clerk is ready or degraded (loaded)')).toBeVisible(); await expect(page.getByText('Clerk is loaded', { exact: true })).toBeVisible(); - await expect(page.getByText('(comp) Clerk is loaded,(ready or degraded)')).toBeVisible(); - // Verify loading component is no longer visible - await expect(page.getByText('(comp) Waiting for clerk to fail, ready or regraded.')).toBeHidden(); + // Verify retry happened + expect(requestCount).toBeGreaterThan(1); }); - test('clerk-js hotloading failed', async ({ page, context }) => { + test('recovers from HTTP 500 error on clerk-js script load', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); - await page.route('**/clerk.browser.js', route => route.abort()); + let requestCount = 0; + await page.route('**/clerk.browser.js', route => { + requestCount++; + // Return 500 error on first request, succeed on subsequent + if (requestCount === 1) { + return route.fulfill({ + status: 500, + body: 'Internal Server Error', + }); + } + return route.continue(); + }); await u.page.goToRelative('/clerk-status'); - // Initial state checks + // Initial state should show loading await expect(page.getByText('Status: loading', { exact: true })).toBeVisible(); - await expect(page.getByText('Clerk is loading', { exact: true })).toBeVisible(); - 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: 16_000, + // Wait for Clerk to eventually load after retry + await expect(page.getByText('Status: ready', { exact: true })).toBeVisible({ + timeout: 20_000, }); - await expect(page.getByText('Clerk is out', { exact: true })).toBeVisible(); - await expect(page.getByText('Clerk is ready or degraded (loaded)')).toBeHidden(); - await expect(page.getByText('Clerk is loaded', { exact: true })).toBeHidden(); - await expect(page.getByText('(comp) Clerk is loaded,(ready or degraded)')).toBeHidden(); - await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible(); - // Verify loading component is no longer visible - await expect(page.getByText('Clerk is loading', { exact: true })).toBeHidden(); + await u.po.clerk.toBeLoaded(); + + // Verify retry happened + expect(requestCount).toBeGreaterThan(1); }); - test('clerk-js client fails and status degraded', async ({ page, context }) => { + test('recovers from HTTP 503 service unavailable with retry', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); - await page.route('**/v1/client?**', route => route.fulfill(make500ClerkResponse())); + let requestCount = 0; + await page.route('**/clerk.browser.js', route => { + requestCount++; + // Return 503 error on first two requests, succeed on third + if (requestCount <= 2) { + return route.fulfill({ + status: 503, + body: 'Service Unavailable', + }); + } + return route.continue(); + }); await u.page.goToRelative('/clerk-status'); - // Initial state checks - await expect(page.getByText('Status: loading', { exact: true })).toBeVisible(); - await expect(page.getByText('Clerk is loading', { exact: true })).toBeVisible(); - await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible(); - - // Wait for loading to complete and verify final state - await expect(page.getByText('Status: degraded', { exact: true })).toBeVisible({ - timeout: 10_000, + // Wait for Clerk to eventually load after multiple retries + await expect(page.getByText('Status: ready', { exact: true })).toBeVisible({ + timeout: 25_000, }); - await u.po.clerk.toBeDegraded(); - await expect(page.getByText('Clerk is degraded', { exact: true })).toBeVisible(); - await expect(page.getByText('Clerk is ready', { exact: true })).toBeHidden(); - await expect(page.getByText('Clerk is ready or degraded (loaded)')).toBeVisible(); - await expect(page.getByText('Clerk is loaded', { exact: true })).toBeVisible(); - await expect(page.getByText('(comp) Clerk is loaded,(ready or degraded)')).toBeVisible(); - await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeHidden(); - await expect(page.getByText('(comp) Clerk is degraded')).toBeVisible(); - // Verify loading component is no longer visible - await expect(page.getByText('Clerk is loading', { exact: true })).toBeHidden(); + await u.po.clerk.toBeLoaded(); + + // Verify multiple retries happened + expect(requestCount).toBeGreaterThan(2); }); - test('clerk-js environment fails and status degraded', async ({ page, context }) => { + test('fails with error status after exhausting all retries', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); - await page.route('**/v1/environment?**', route => route.fulfill(make500ClerkResponse())); + // Block all clerk.browser.js requests permanently + await page.route('**/clerk.browser.js', route => route.abort('failed')); await u.page.goToRelative('/clerk-status'); - // Initial state checks + // Initial state should show loading await expect(page.getByText('Status: loading', { exact: true })).toBeVisible(); - await expect(page.getByText('Clerk is loading', { exact: true })).toBeVisible(); + + // Wait for error status after all retries are exhausted + // This should take longer due to exponential backoff + await expect(page.getByText('Status: error', { exact: true })).toBeVisible({ + timeout: 30_000, + }); + + await expect(page.getByText('Clerk is out', { exact: true })).toBeVisible(); await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible(); - await u.po.clerk.toBeLoading(); - - // Wait for loading to complete and verify final state - await expect(page.getByText('Status: degraded', { exact: true })).toBeVisible(); - await u.po.clerk.toBeDegraded(); - await expect(page.getByText('Clerk is degraded', { exact: true })).toBeVisible(); - await expect(page.getByText('Clerk is ready', { exact: true })).toBeHidden(); - await expect(page.getByText('Clerk is ready or degraded (loaded)')).toBeVisible(); - await expect(page.getByText('Clerk is loaded', { exact: true })).toBeVisible(); - await expect(page.getByText('(comp) Clerk is loaded,(ready or degraded)')).toBeVisible(); - await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeHidden(); - await expect(page.getByText('(comp) Clerk is degraded')).toBeVisible(); + }); - // Verify loading component is no longer visible - await expect(page.getByText('Clerk is loading', { exact: true })).toBeHidden(); + test('handles slow network with eventual success', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + let requestCount = 0; + await page.route('**/clerk.browser.js', async route => { + requestCount++; + // First request times out (simulate by very long delay) + if (requestCount === 1) { + // Wait longer than typical timeout, then abort + await new Promise(resolve => setTimeout(resolve, 3000)); + return route.abort('timedout'); + } + // Second request succeeds normally + return route.continue(); + }); + + await u.page.goToRelative('/clerk-status'); + + // Wait for Clerk to eventually load + await expect(page.getByText('Status: ready', { exact: true })).toBeVisible({ + timeout: 25_000, + }); + + await u.po.clerk.toBeLoaded(); }); }); }); diff --git a/packages/shared/src/loadClerkJsScript.ts b/packages/shared/src/loadClerkJsScript.ts index 6e8459da6f2..67ebead980f 100644 --- a/packages/shared/src/loadClerkJsScript.ts +++ b/packages/shared/src/loadClerkJsScript.ts @@ -71,13 +71,14 @@ function hasScriptRequestError(scriptUrl: string): boolean { return false; } - const entries = performance.getEntries() as PerformanceResourceTiming[]; - const scriptEntry = entries.find(entry => entry.name === scriptUrl); + const entries = performance.getEntriesByName(scriptUrl, 'resource') as PerformanceResourceTiming[]; - if (!scriptEntry) { + if (entries.length === 0) { return false; } + const scriptEntry = entries[entries.length - 1]; + // transferSize === 0 with responseEnd === 0 indicates network failure // transferSize === 0 with responseEnd > 0 might be a 4xx/5xx error or blocked request if (scriptEntry.transferSize === 0 && scriptEntry.decodedBodySize === 0) { @@ -90,8 +91,14 @@ function hasScriptRequestError(scriptUrl: string): boolean { return true; } - if (scriptEntry.responseStatus === 0) { - return true; + if ('responseStatus' in scriptEntry) { + const status = (scriptEntry as any).responseStatus; + if (status >= 400) { + return true; + } + if (scriptEntry.responseStatus === 0) { + return true; + } } } @@ -103,9 +110,13 @@ function hasScriptRequestError(scriptUrl: string): boolean { * Uses polling to check if Clerk becomes available within the specified timeout. * * @param timeoutMs - Maximum time to wait in milliseconds. + * @param existingScript - The existing script element to wait for. Optional, for existing scripts. * @returns Promise that resolves with null if Clerk loads successfully, or rejects with an error if timeout is reached. */ -function waitForClerkWithTimeout(timeoutMs: number): Promise { +function waitForClerkWithTimeout( + timeoutMs: number, + existingScript?: HTMLScriptElement, +): Promise { return new Promise((resolve, reject) => { let resolved = false; @@ -114,6 +125,12 @@ function waitForClerkWithTimeout(timeoutMs: number): Promise { + cleanup(timeoutId, pollInterval); + reject(new ClerkRuntimeError(FAILED_TO_LOAD_ERROR, { code: ERROR_CODE })); + }); + const checkAndResolve = () => { if (resolved) { return; @@ -198,7 +215,8 @@ const loadClerkJsScript = async (opts?: LoadClerkJsScriptOptions): Promise Date: Mon, 24 Nov 2025 22:12:18 -0600 Subject: [PATCH 5/7] fix typo --- .../templates/next-app-router/src/app/clerk-status/page.tsx | 2 +- integration/templates/react-vite/src/clerk-status/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/templates/next-app-router/src/app/clerk-status/page.tsx b/integration/templates/next-app-router/src/app/clerk-status/page.tsx index 2f871185f54..256228b16c8 100644 --- a/integration/templates/next-app-router/src/app/clerk-status/page.tsx +++ b/integration/templates/next-app-router/src/app/clerk-status/page.tsx @@ -28,7 +28,7 @@ export default function ClerkStatusPage() { -

(comp) Waiting for clerk to fail, ready or regraded.

+

(comp) Waiting for clerk to fail, ready or degraded.

); diff --git a/integration/templates/react-vite/src/clerk-status/index.tsx b/integration/templates/react-vite/src/clerk-status/index.tsx index b8cbfd2c0b0..eaae6b01baf 100644 --- a/integration/templates/react-vite/src/clerk-status/index.tsx +++ b/integration/templates/react-vite/src/clerk-status/index.tsx @@ -27,7 +27,7 @@ export default function ClerkStatusPage() { -

(comp) Waiting for clerk to fail, ready or regraded.

+

(comp) Waiting for clerk to fail, ready or degraded.

); From a33cc0247a2d8f598ce770b0fe295804c127300c Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Mon, 24 Nov 2025 22:13:02 -0600 Subject: [PATCH 6/7] fix more typos --- integration/tests/resiliency.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/tests/resiliency.test.ts b/integration/tests/resiliency.test.ts index 1ce0c5db583..561c03130e0 100644 --- a/integration/tests/resiliency.test.ts +++ b/integration/tests/resiliency.test.ts @@ -152,7 +152,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible(); await expect(page.getByText('Clerk is out')).toBeHidden(); await expect(page.getByText('Clerk is degraded')).toBeHidden(); - await expect(page.getByText('(comp) Waiting for clerk to fail, ready or regraded.')).toBeVisible(); + await expect(page.getByText('(comp) Waiting for clerk to fail, ready or degraded.')).toBeVisible(); await u.po.clerk.toBeLoading(); // Wait for loading to complete and verify final state @@ -165,7 +165,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc await expect(page.getByText('(comp) Clerk is loaded,(ready or degraded)')).toBeVisible(); // Verify loading component is no longer visible - await expect(page.getByText('(comp) Waiting for clerk to fail, ready or regraded.')).toBeHidden(); + await expect(page.getByText('(comp) Waiting for clerk to fail, ready or degraded.')).toBeHidden(); }); test('clerk-js hotloading failed', async ({ page, context }) => { From ed9dd9f27c41bbe88e750b8f8a4d4753dda765ee Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Mon, 24 Nov 2025 22:17:13 -0600 Subject: [PATCH 7/7] adds changeset --- .changeset/lazy-items-crash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lazy-items-crash.md diff --git a/.changeset/lazy-items-crash.md b/.changeset/lazy-items-crash.md new file mode 100644 index 00000000000..bd6378741a8 --- /dev/null +++ b/.changeset/lazy-items-crash.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': minor +--- + +Improve error handling and retry logic when loading `@clerk/clerk-js`.