From 225747c6cc9130876d6e13ee3a94e82a54aeee17 Mon Sep 17 00:00:00 2001 From: seanperez Date: Tue, 27 Oct 2026 13:03:52 -0400 Subject: [PATCH 1/9] fix(clerk-js): finish a failed native load when its resources reload Co-Authored-By: Claude Opus 5.5 --- .../clerk-js-finish-failed-native-load.md | 5 ++ .../clerk-js/src/core/__tests__/clerk.test.ts | 78 +++++++++++++++++++ packages/clerk-js/src/core/clerk.ts | 52 ++++++++----- 3 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 .changeset/clerk-js-finish-failed-native-load.md diff --git a/.changeset/clerk-js-finish-failed-native-load.md b/.changeset/clerk-js-finish-failed-native-load.md new file mode 100644 index 00000000000..13889bef387 --- /dev/null +++ b/.changeset/clerk-js-finish-failed-native-load.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +On iOS and Android, let `@clerk/expo` recover when Clerk fails to load, such as while Clerk rejects an outdated app version. A later successful reload of the initial resources now finishes loading the same Clerk instance, instead of leaving it failed until the app restarts. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 189f3ab911f..82aa818d76e 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -1106,6 +1106,84 @@ describe('Clerk singleton', () => { expect(sut.__internal_getCachedResources).not.toHaveBeenCalled(); }); }); + + describe('__internal_reloadInitialResources after a failed load', () => { + const unsupportedAppVersion = () => + new ClerkAPIResponseError('Unsupported app version', { data: [], status: 403 }); + + const loadFailingSut = async () => { + const sut = new Clerk(productionPublishableKey); + mockClientFetch.mockRejectedValueOnce(unsupportedAppVersion()); + await expect(sut.load({ standardBrowser: false })).rejects.toBeInstanceOf(ClerkAPIResponseError); + const statuses: string[] = []; + sut.on('status', status => statuses.push(status)); + return { sut, statuses }; + }; + + it('finishes loading the same instance once the reload succeeds', async () => { + const { sut, statuses } = await loadFailingSut(); + const client = { signedInSessions: [] }; + mockClientFetch.mockResolvedValueOnce(client); + + await sut.__internal_reloadInitialResources(); + + expect(sut.status).toBe('ready'); + expect(sut.loaded).toBe(true); + expect(sut.client).toBe(client); + expect(statuses).toEqual(['ready']); + }); + + it('stays failed while the reload is rejected, and finishes on a later reload', async () => { + const { sut, statuses } = await loadFailingSut(); + mockClientFetch.mockRejectedValueOnce(unsupportedAppVersion()); + + await expect(sut.__internal_reloadInitialResources()).rejects.toBeInstanceOf(ClerkAPIResponseError); + expect(sut.status).toBe('error'); + + mockClientFetch.mockResolvedValueOnce({ signedInSessions: [] }); + await sut.__internal_reloadInitialResources(); + + expect(sut.status).toBe('ready'); + expect(statuses).toEqual(['ready']); + }); + + it('finishes loading once when reloads overlap', async () => { + const { sut, statuses } = await loadFailingSut(); + mockClientFetch.mockResolvedValue({ signedInSessions: [] }); + + await Promise.all([sut.__internal_reloadInitialResources(), sut.__internal_reloadInitialResources()]); + + expect(sut.status).toBe('ready'); + expect(statuses).toEqual(['ready']); + }); + + it('leaves a load that is still running to report its own result', async () => { + const sut = new Clerk(productionPublishableKey); + const firstClientFetch = createDeferredPromise(); + mockClientFetch.mockReturnValueOnce(firstClientFetch.promise).mockResolvedValueOnce({ signedInSessions: [] }); + + const load = sut.load({ standardBrowser: false }); + await sut.__internal_reloadInitialResources(); + expect(sut.status).toBe('loading'); + + firstClientFetch.reject(unsupportedAppVersion()); + await expect(load).rejects.toBeInstanceOf(ClerkAPIResponseError); + expect(sut.status).toBe('error'); + }); + + it('does not report ready again after a load that succeeded', async () => { + const sut = new Clerk(productionPublishableKey); + mockClientFetch.mockResolvedValue({ signedInSessions: [] }); + await sut.load({ standardBrowser: false }); + const statuses: string[] = []; + sut.on('status', status => statuses.push(status)); + + await sut.__internal_reloadInitialResources(); + + expect(sut.status).toBe('ready'); + expect(statuses).toEqual([]); + }); + }); }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b5ca5d2e97c..e1d17369f59 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -680,25 +680,7 @@ export class Clerk implements ClerkInterface { } else { await this.#loadInNonStandardBrowser(); } - const telemetry = this.#options.telemetry; - const telemetryEnabled = telemetry !== false && !telemetry?.disabled; - - const isKeyless = Boolean(this.#options.__internal_keyless_claimKeylessApplicationUrl); - const hasClientDebugMode = Boolean(this.environment?.clientDebugMode); - const isProd = this.environment?.isProduction?.() ?? false; - - const shouldEnable = hasClientDebugMode || (isKeyless && !isProd); - const logLevel = isKeyless && !hasClientDebugMode ? 'error' : undefined; - - if (shouldEnable) { - initDebugLogger({ - enabled: true, - ...(logLevel ? { logLevel } : {}), - ...(telemetryEnabled && this.telemetry ? { telemetryCollector: this.telemetry } : {}), - }); - } - this.#protect?.load(this.environment as Environment); - debugLogger.info('load() complete', {}, 'clerk'); + this.#finishLoad(); } catch (error) { this.#publicEventBus.emit(clerkEvents.Status, 'error'); debugLogger.error('load() failed', { error }, 'clerk'); @@ -707,6 +689,28 @@ export class Clerk implements ClerkInterface { } }; + #finishLoad = (): void => { + const telemetry = this.#options.telemetry; + const telemetryEnabled = telemetry !== false && !telemetry?.disabled; + + const isKeyless = Boolean(this.#options.__internal_keyless_claimKeylessApplicationUrl); + const hasClientDebugMode = Boolean(this.environment?.clientDebugMode); + const isProd = this.environment?.isProduction?.() ?? false; + + const shouldEnable = hasClientDebugMode || (isKeyless && !isProd); + const logLevel = isKeyless && !hasClientDebugMode ? 'error' : undefined; + + if (shouldEnable) { + initDebugLogger({ + enabled: true, + ...(logLevel ? { logLevel } : {}), + ...(telemetryEnabled && this.telemetry ? { telemetryCollector: this.telemetry } : {}), + }); + } + this.#protect?.load(this.environment as Environment); + debugLogger.info('load() complete', {}, 'clerk'); + }; + #isCombinedSignInOrUpFlow(): boolean { return Boolean(!this.#options.signUpUrl && this.#options.signInUrl && !isAbsoluteUrl(this.#options.signInUrl)); } @@ -3684,8 +3688,10 @@ export class Clerk implements ClerkInterface { this.#publicEventBus.emit(clerkEvents.Status, initializationDegradedCounter > 0 ? 'degraded' : 'ready'); }; - // This is used by @clerk/expo + // This is used by @clerk/expo. When `load()` failed, such as while the app version was unsupported, a successful + // reload finishes loading this instance instead of the app having to create a new one. __internal_reloadInitialResources = async (): Promise => { + const finishesFailedLoad = this.#status === 'error'; const [environment, client] = await Promise.all([ Environment.getInstance().fetch({ touch: false, fetchMaxTries: 1 }), Client.getOrCreateInstance().fetch({ fetchMaxTries: 1 }), @@ -3694,6 +3700,12 @@ export class Clerk implements ClerkInterface { this.updateClient(client); this.updateEnvironment(environment); + // An overlapping reload may have finished loading first. + if (finishesFailedLoad && this.#status === 'error') { + this.#finishLoad(); + this.#publicEventBus.emit(clerkEvents.Status, 'ready'); + } + this.#emit(); }; From 27a9faacf361e0d4234402a45d2a477b1b17ac70 Mon Sep 17 00:00:00 2001 From: seanperez Date: Tue, 27 Oct 2026 13:03:54 -0400 Subject: [PATCH 2/9] fix(react): replay queued calls when a failed headless load finishes Co-Authored-By: Claude Opus 5.5 --- ...react-replay-after-finished-native-load.md | 5 ++ .../src/__tests__/isomorphicClerk.test.ts | 75 +++++++++++++++++++ packages/react/src/isomorphicClerk.ts | 24 ++++++ 3 files changed, 104 insertions(+) create mode 100644 .changeset/react-replay-after-finished-native-load.md diff --git a/.changeset/react-replay-after-finished-native-load.md b/.changeset/react-replay-after-finished-native-load.md new file mode 100644 index 00000000000..9c5ad64dddf --- /dev/null +++ b/.changeset/react-replay-after-finished-native-load.md @@ -0,0 +1,5 @@ +--- +'@clerk/react': patch +--- + +When a Clerk instance passed to `` on iOS and Android fails to load and later finishes loading, `` now reports it as loaded and runs the calls it queued while loading, without needing a new instance. diff --git a/packages/react/src/__tests__/isomorphicClerk.test.ts b/packages/react/src/__tests__/isomorphicClerk.test.ts index 43c1e8a6e84..76ebd72cfa4 100644 --- a/packages/react/src/__tests__/isomorphicClerk.test.ts +++ b/packages/react/src/__tests__/isomorphicClerk.test.ts @@ -1,3 +1,4 @@ +import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus'; import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript'; import type { BrowserClerk, @@ -290,6 +291,80 @@ describe('isomorphicClerk', () => { expect(handleResourceCallback).toHaveBeenCalledWith(signInOrUp, params, customNavigate); }); + describe('a headless Clerk instance whose load failed', () => { + function createFailingHeadlessClerk() { + const eventBus = createClerkEventBus(); + let status = 'loading'; + eventBus.prioritizedOn(clerkEvents.Status, next => (status = next)); + const clerkjs = { + get loaded() { + return status === 'ready' || status === 'degraded'; + }, + get status() { + return status; + }, + load: vi.fn(() => { + eventBus.emit(clerkEvents.Status, 'error'); + return Promise.reject(new Error('Unsupported app version')); + }), + on: eventBus.on, + off: eventBus.off, + addListener: vi.fn(), + finishLoad: (next: 'ready' | 'degraded' = 'ready') => eventBus.emit(clerkEvents.Status, next), + }; + const isomorphicClerk = new IsomorphicClerk({ + publishableKey: 'pk_test_XXX', + Clerk: clerkjs as any, + experimental: { runtimeEnvironment: 'headless' }, + }); + return { clerkjs, isomorphicClerk }; + } + + it('replays the queued calls once when clerk-js finishes loading the same instance', async () => { + const { clerkjs, isomorphicClerk } = createFailingHeadlessClerk(); + const statuses: string[] = []; + isomorphicClerk.on('status', status => statuses.push(status)); + isomorphicClerk.addListener(vi.fn()); + await vi.advanceTimersByTimeAsync(0); + + expect(isomorphicClerk.status).toBe('error'); + expect(clerkjs.addListener).not.toHaveBeenCalled(); + + clerkjs.finishLoad(); + await vi.advanceTimersByTimeAsync(0); + clerkjs.finishLoad(); + await vi.advanceTimersByTimeAsync(0); + + expect(isomorphicClerk.status).toBe('ready'); + expect(isomorphicClerk.loaded).toBe(true); + expect(clerkjs.addListener).toHaveBeenCalledTimes(1); + expect(statuses).toContain('ready'); + }); + + it('waits while clerk-js is still failing', async () => { + const { clerkjs, isomorphicClerk } = createFailingHeadlessClerk(); + isomorphicClerk.addListener(vi.fn()); + await vi.advanceTimersByTimeAsync(0); + + clerkjs.finishLoad('error' as 'ready'); + await vi.advanceTimersByTimeAsync(0); + + expect(isomorphicClerk.status).toBe('error'); + expect(clerkjs.addListener).not.toHaveBeenCalled(); + }); + + it('lets every other status listener on clerk-js see the finished load', async () => { + const { clerkjs } = createFailingHeadlessClerk(); + await vi.advanceTimersByTimeAsync(0); + const laterListener = vi.fn(); + clerkjs.on('status', laterListener); + + clerkjs.finishLoad(); + + expect(laterListener).toHaveBeenCalledWith('ready'); + }); + }); + describe('__internal_* URL precedence', () => { it('__internal_clerkJSUrl causes script loading even when Clerk prop is provided', async () => { const mockClerkCtor = vi.fn().mockImplementation(() => ({ diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 6b972ec0fdd..545ba68fb1e 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -367,12 +367,36 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } this.#eventBus.emit(clerkEvents.Status, 'error'); this.emitLoaded(); + this.replayWhenLoadIsFinished(clerk); }); } else { onLoaded(); } } + /** + * clerk-js can finish a failed load on the same instance, such as when `@clerk/expo` reloads its initial resources + * once Clerk stops rejecting the app's version. + */ + private replayWhenLoadIsFinished(clerk: HeadlessBrowserClerk | BrowserClerk): void { + if (clerk.loaded || typeof clerk.on !== 'function') { + return; + } + let finished = false; + const onStatus = (status: ClerkStatus) => { + if (finished || (status !== 'ready' && status !== 'degraded')) { + return; + } + finished = true; + // Unsubscribing while the event bus dispatches would skip the next listener. + queueMicrotask(() => { + clerk.off('status', onStatus); + this.replayInterceptedInvocations(clerk); + }); + }; + clerk.on('status', onStatus); + } + get sdkMetadata() { return this.clerkjs?.sdkMetadata || this.options.sdkMetadata || undefined; } From abbce9f28e3372484fb01a8218be373b89dc3f82 Mon Sep 17 00:00:00 2001 From: seanperez Date: Tue, 27 Oct 2026 13:19:02 -0400 Subject: [PATCH 3/9] fix(react): replay when a reload finishes the load before its failure is handled Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/isomorphicClerk.test.ts | 46 ++++++++++++++++++- packages/react/src/isomorphicClerk.ts | 16 ++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/react/src/__tests__/isomorphicClerk.test.ts b/packages/react/src/__tests__/isomorphicClerk.test.ts index 76ebd72cfa4..9e1ce7bfa06 100644 --- a/packages/react/src/__tests__/isomorphicClerk.test.ts +++ b/packages/react/src/__tests__/isomorphicClerk.test.ts @@ -292,7 +292,7 @@ describe('isomorphicClerk', () => { }); describe('a headless Clerk instance whose load failed', () => { - function createFailingHeadlessClerk() { + function createFailingHeadlessClerk({ finishesBeforeLoadRejects = false } = {}) { const eventBus = createClerkEventBus(); let status = 'loading'; eventBus.prioritizedOn(clerkEvents.Status, next => (status = next)); @@ -305,6 +305,9 @@ describe('isomorphicClerk', () => { }, load: vi.fn(() => { eventBus.emit(clerkEvents.Status, 'error'); + if (finishesBeforeLoadRejects) { + eventBus.emit(clerkEvents.Status, 'ready'); + } return Promise.reject(new Error('Unsupported app version')); }), on: eventBus.on, @@ -341,6 +344,47 @@ describe('isomorphicClerk', () => { expect(statuses).toContain('ready'); }); + it('replays the queued calls when clerk-js finished loading before the failure was handled', async () => { + const { clerkjs, isomorphicClerk } = createFailingHeadlessClerk({ finishesBeforeLoadRejects: true }); + isomorphicClerk.addListener(vi.fn()); + await vi.advanceTimersByTimeAsync(0); + + expect(isomorphicClerk.status).toBe('ready'); + expect(clerkjs.addListener).toHaveBeenCalledTimes(1); + }); + + it('replays once when the replay after a successful load throws', async () => { + const eventBus = createClerkEventBus(); + let status = 'loading'; + eventBus.prioritizedOn(clerkEvents.Status, next => (status = next)); + const clerkjs = { + get loaded() { + return status === 'ready'; + }, + get status() { + return status; + }, + load: vi.fn(() => { + eventBus.emit(clerkEvents.Status, 'ready'); + return Promise.resolve(); + }), + on: eventBus.on, + off: eventBus.off, + addListener: vi.fn(() => { + throw new Error('listener failed'); + }), + }; + const isomorphicClerk = new IsomorphicClerk({ + publishableKey: 'pk_test_XXX', + Clerk: clerkjs as any, + experimental: { runtimeEnvironment: 'headless' }, + }); + isomorphicClerk.addListener(vi.fn()); + await vi.advanceTimersByTimeAsync(0); + + expect(clerkjs.addListener).toHaveBeenCalledTimes(1); + }); + it('waits while clerk-js is still failing', async () => { const { clerkjs, isomorphicClerk } = createFailingHeadlessClerk(); isomorphicClerk.addListener(vi.fn()); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 545ba68fb1e..8dbe7b86e45 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -358,8 +358,13 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }; if (!clerk.loaded) { + let loadFailed = false; clerk .load(this.options) + .catch(err => { + loadFailed = true; + throw err; + }) .then(() => onLoaded()) .catch(err => { if (__DEV__) { @@ -367,7 +372,9 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } this.#eventBus.emit(clerkEvents.Status, 'error'); this.emitLoaded(); - this.replayWhenLoadIsFinished(clerk); + if (loadFailed) { + this.replayWhenLoadIsFinished(clerk); + } }); } else { onLoaded(); @@ -379,7 +386,12 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { * once Clerk stops rejecting the app's version. */ private replayWhenLoadIsFinished(clerk: HeadlessBrowserClerk | BrowserClerk): void { - if (clerk.loaded || typeof clerk.on !== 'function') { + // A reload can finish the load before this runs. + if (clerk.loaded) { + this.replayInterceptedInvocations(clerk); + return; + } + if (typeof clerk.on !== 'function') { return; } let finished = false; From b64884e3dd913695df2370438d4246d6f34b685d Mon Sep 17 00:00:00 2001 From: seanperez Date: Tue, 27 Oct 2026 14:43:53 -0400 Subject: [PATCH 4/9] fix(clerk-js): finish a load that failed while a reload was running Co-Authored-By: Claude Opus 5.5 --- .../clerk-js/src/core/__tests__/clerk.test.ts | 18 ++++++++++++++++++ packages/clerk-js/src/core/clerk.ts | 6 +++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 82aa818d76e..e2bcb06937a 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -1171,6 +1171,24 @@ describe('Clerk singleton', () => { expect(sut.status).toBe('error'); }); + it('finishes a load that failed while the reload was running', async () => { + const sut = new Clerk(productionPublishableKey); + const firstClientFetch = createDeferredPromise(); + const reloadClientFetch = createDeferredPromise(); + mockClientFetch.mockReturnValueOnce(firstClientFetch.promise).mockReturnValueOnce(reloadClientFetch.promise); + + const load = sut.load({ standardBrowser: false }); + const reload = sut.__internal_reloadInitialResources(); + firstClientFetch.reject(unsupportedAppVersion()); + await expect(load).rejects.toBeInstanceOf(ClerkAPIResponseError); + expect(sut.status).toBe('error'); + + reloadClientFetch.resolve({ signedInSessions: [] }); + await reload; + + expect(sut.status).toBe('ready'); + }); + it('does not report ready again after a load that succeeded', async () => { const sut = new Clerk(productionPublishableKey); mockClientFetch.mockResolvedValue({ signedInSessions: [] }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index e1d17369f59..30879ae25ae 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -3691,7 +3691,6 @@ export class Clerk implements ClerkInterface { // This is used by @clerk/expo. When `load()` failed, such as while the app version was unsupported, a successful // reload finishes loading this instance instead of the app having to create a new one. __internal_reloadInitialResources = async (): Promise => { - const finishesFailedLoad = this.#status === 'error'; const [environment, client] = await Promise.all([ Environment.getInstance().fetch({ touch: false, fetchMaxTries: 1 }), Client.getOrCreateInstance().fetch({ fetchMaxTries: 1 }), @@ -3700,8 +3699,9 @@ export class Clerk implements ClerkInterface { this.updateClient(client); this.updateEnvironment(environment); - // An overlapping reload may have finished loading first. - if (finishesFailedLoad && this.#status === 'error') { + // Only a load that has already failed is finished here. A load that is still running reports its own result, and + // an overlapping reload may have finished loading first. + if (this.#status === 'error') { this.#finishLoad(); this.#publicEventBus.emit(clerkEvents.Status, 'ready'); } From ebb76503bc72142c2ed61e68acc063985c0abe0d Mon Sep 17 00:00:00 2001 From: seanperez Date: Tue, 27 Oct 2026 14:43:55 -0400 Subject: [PATCH 5/9] fix(react): handle a throwing replay after a failed headless load finishes Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/isomorphicClerk.test.ts | 30 +++++++++++++++++-- packages/react/src/isomorphicClerk.ts | 17 +++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/react/src/__tests__/isomorphicClerk.test.ts b/packages/react/src/__tests__/isomorphicClerk.test.ts index 9e1ce7bfa06..ebaf77e3dd5 100644 --- a/packages/react/src/__tests__/isomorphicClerk.test.ts +++ b/packages/react/src/__tests__/isomorphicClerk.test.ts @@ -292,7 +292,7 @@ describe('isomorphicClerk', () => { }); describe('a headless Clerk instance whose load failed', () => { - function createFailingHeadlessClerk({ finishesBeforeLoadRejects = false } = {}) { + function createFailingHeadlessClerk({ finishesBeforeLoadRejects = false, throwingListener = false } = {}) { const eventBus = createClerkEventBus(); let status = 'loading'; eventBus.prioritizedOn(clerkEvents.Status, next => (status = next)); @@ -312,7 +312,11 @@ describe('isomorphicClerk', () => { }), on: eventBus.on, off: eventBus.off, - addListener: vi.fn(), + addListener: vi.fn(() => { + if (throwingListener) { + throw new Error('listener failed'); + } + }), finishLoad: (next: 'ready' | 'degraded' = 'ready') => eventBus.emit(clerkEvents.Status, next), }; const isomorphicClerk = new IsomorphicClerk({ @@ -385,6 +389,28 @@ describe('isomorphicClerk', () => { expect(clerkjs.addListener).toHaveBeenCalledTimes(1); }); + it.each([ + ['after the failure was handled', false], + ['before the failure was handled', true], + ])('reports an error when the replay throws for a load that finished %s', async (_, finishesBeforeLoadRejects) => { + const { clerkjs, isomorphicClerk } = createFailingHeadlessClerk({ + finishesBeforeLoadRejects, + throwingListener: true, + }); + const statuses: string[] = []; + isomorphicClerk.on('status', status => statuses.push(status)); + isomorphicClerk.addListener(vi.fn()); + await vi.advanceTimersByTimeAsync(0); + + if (!finishesBeforeLoadRejects) { + clerkjs.finishLoad(); + await vi.advanceTimersByTimeAsync(0); + } + + expect(clerkjs.addListener).toHaveBeenCalledTimes(1); + expect(statuses.at(-1)).toBe('error'); + }); + it('waits while clerk-js is still failing', async () => { const { clerkjs, isomorphicClerk } = createFailingHeadlessClerk(); isomorphicClerk.addListener(vi.fn()); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 8dbe7b86e45..ace749035d0 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -388,7 +388,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private replayWhenLoadIsFinished(clerk: HeadlessBrowserClerk | BrowserClerk): void { // A reload can finish the load before this runs. if (clerk.loaded) { - this.replayInterceptedInvocations(clerk); + this.replayFinishedLoad(clerk); return; } if (typeof clerk.on !== 'function') { @@ -403,12 +403,25 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { // Unsubscribing while the event bus dispatches would skip the next listener. queueMicrotask(() => { clerk.off('status', onStatus); - this.replayInterceptedInvocations(clerk); + this.replayFinishedLoad(clerk); }); }; clerk.on('status', onStatus); } + // Nothing awaits this replay, so a queued call or listener that throws is handled here, as it is when the first load + // succeeds. + private replayFinishedLoad(clerk: HeadlessBrowserClerk | BrowserClerk): void { + try { + this.replayInterceptedInvocations(clerk); + } catch (err) { + if (__DEV__) { + console.error('Clerk: Failed to load:', err); + } + this.#eventBus.emit(clerkEvents.Status, 'error'); + } + } + get sdkMetadata() { return this.clerkjs?.sdkMetadata || this.options.sdkMetadata || undefined; } From ea95ddc8ea8cacac1b798f84468a3ad111b4098f Mon Sep 17 00:00:00 2001 From: seanperez Date: Tue, 27 Oct 2026 14:57:49 -0400 Subject: [PATCH 6/9] fix(clerk-js): finish a failed load when a reload succeeded while it ran Co-Authored-By: Claude Opus 5.5 --- .../clerk-js/src/core/__tests__/clerk.test.ts | 23 +++++++++++++++++-- packages/clerk-js/src/core/clerk.ts | 13 +++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index e2bcb06937a..52096724963 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -1157,15 +1157,34 @@ describe('Clerk singleton', () => { expect(statuses).toEqual(['ready']); }); - it('leaves a load that is still running to report its own result', async () => { + it('finishes a load that fails after a reload succeeded while it was running', async () => { const sut = new Clerk(productionPublishableKey); const firstClientFetch = createDeferredPromise(); - mockClientFetch.mockReturnValueOnce(firstClientFetch.promise).mockResolvedValueOnce({ signedInSessions: [] }); + const client = { signedInSessions: [] }; + mockClientFetch.mockReturnValueOnce(firstClientFetch.promise).mockResolvedValueOnce(client); + const statuses: string[] = []; + sut.on('status', status => statuses.push(status)); const load = sut.load({ standardBrowser: false }); await sut.__internal_reloadInitialResources(); expect(sut.status).toBe('loading'); + firstClientFetch.reject(unsupportedAppVersion()); + await expect(load).resolves.toBeUndefined(); + expect(sut.status).toBe('ready'); + expect(sut.client).toBe(client); + expect(statuses.filter(status => status === 'ready')).toHaveLength(1); + expect(statuses).not.toContain('error'); + }); + + it('lets a load fail when no reload succeeded while it was running', async () => { + const sut = new Clerk(productionPublishableKey); + const firstClientFetch = createDeferredPromise(); + mockClientFetch.mockReturnValueOnce(firstClientFetch.promise).mockRejectedValueOnce(unsupportedAppVersion()); + + const load = sut.load({ standardBrowser: false }); + await expect(sut.__internal_reloadInitialResources()).rejects.toBeInstanceOf(ClerkAPIResponseError); + firstClientFetch.reject(unsupportedAppVersion()); await expect(load).rejects.toBeInstanceOf(ClerkAPIResponseError); expect(sut.status).toBe('error'); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 30879ae25ae..448b446b80b 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -279,6 +279,7 @@ export class Clerk implements ClerkInterface { #fapiClient: FapiClient; #instanceType?: InstanceType; #status: ClerkInterface['status'] = 'loading'; + #initialResourcesReloadedDuringLoad = false; #listeners: Array<(emission: Resources) => void> = []; #navigationListeners: Array<() => void> = []; #options: ClerkOptions = {}; @@ -682,6 +683,12 @@ export class Clerk implements ClerkInterface { } this.#finishLoad(); } catch (error) { + // A reload that succeeded while this load was running already applied newer resources, so the load finishes. + if (this.#initialResourcesReloadedDuringLoad && this.#status === 'loading') { + this.#finishLoad(); + this.#publicEventBus.emit(clerkEvents.Status, 'ready'); + return; + } this.#publicEventBus.emit(clerkEvents.Status, 'error'); debugLogger.error('load() failed', { error }, 'clerk'); // bubble up the error @@ -3699,11 +3706,13 @@ export class Clerk implements ClerkInterface { this.updateClient(client); this.updateEnvironment(environment); - // Only a load that has already failed is finished here. A load that is still running reports its own result, and - // an overlapping reload may have finished loading first. + // A load that is still running finishes with these resources if it fails. An overlapping reload may have finished + // loading first. if (this.#status === 'error') { this.#finishLoad(); this.#publicEventBus.emit(clerkEvents.Status, 'ready'); + } else if (this.#status === 'loading') { + this.#initialResourcesReloadedDuringLoad = true; } this.#emit(); From 87366535ec45aed55f55c88d82e804f5bb893ed2 Mon Sep 17 00:00:00 2001 From: seanperez Date: Tue, 27 Oct 2026 16:37:06 -0400 Subject: [PATCH 7/9] fix(clerk-js): accept a successful response with an empty body Co-Authored-By: Claude Opus 5.5 --- .../clerk-js-empty-successful-response.md | 5 +++ .../src/core/__tests__/fapiClient.test.ts | 34 +++++++++++++++++++ packages/clerk-js/src/core/fapiClient.ts | 20 +++++------ 3 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 .changeset/clerk-js-empty-successful-response.md diff --git a/.changeset/clerk-js-empty-successful-response.md b/.changeset/clerk-js-empty-successful-response.md new file mode 100644 index 00000000000..df39ec00aec --- /dev/null +++ b/.changeset/clerk-js-empty-successful-response.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Accept a successful Frontend API response with an empty body. The request now resolves with a `null` payload and runs its response callbacks, as it does for a `204 No Content` response, instead of failing to parse the empty body as JSON. diff --git a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts index aeb7273a1d0..693eedad06d 100644 --- a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts +++ b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts @@ -244,6 +244,40 @@ describe('request', () => { expect(resp.payload).toEqual(null); }); + it('returns a null payload and runs the after response callbacks on a successful response with an empty body', async () => { + const client = createFapiClient(baseFapiClientOptions); + const afterResponse = vi.fn(); + client.onAfterResponse(afterResponse); + (global.fetch as Mock).mockResolvedValueOnce( + Promise.resolve>({ + status: 200, + ok: true, + headers: { get: (name: string) => (name.toLowerCase() === 'content-length' ? '0' : null) }, + json: () => Promise.reject(new SyntaxError('Unexpected end of JSON input')), + }), + ); + + const resp = await client.request({ path: '/' }); + + expect(resp.status).toBe(200); + expect(resp.payload).toEqual(null); + expect(afterResponse).toHaveBeenCalledTimes(1); + }); + + it('still rejects a successful response whose body is not JSON', async () => { + const client = createFapiClient(baseFapiClientOptions); + (global.fetch as Mock).mockResolvedValueOnce( + Promise.resolve>({ + status: 200, + ok: true, + headers: { get: (name: string) => (name.toLowerCase() === 'content-length' ? '12' : null) }, + json: () => Promise.reject(new SyntaxError('Unexpected token < in JSON')), + }), + ); + + await expect(client.request({ path: '/foo' })).rejects.toBeInstanceOf(SyntaxError); + }); + it('returns a null payload and skips the after response callbacks on a non-JSON 5xx response', async () => { const client = createFapiClient(baseFapiClientOptions); const afterResponse = vi.fn(); diff --git a/packages/clerk-js/src/core/fapiClient.ts b/packages/clerk-js/src/core/fapiClient.ts index 81203f29e8b..00c3a185325 100644 --- a/packages/clerk-js/src/core/fapiClient.ts +++ b/packages/clerk-js/src/core/fapiClient.ts @@ -322,18 +322,18 @@ export function createFapiClient(options: FapiClientOptions): FapiClient { clerkNetworkError(urlStr, e); } - // 204 No Content responses do not have a body so we should not try to parse it. + // 204 No Content responses, and successful responses that declare an empty body, have nothing to parse. // Gateway errors may not be JSON (e.g. a proxy's HTML 502); they resolve to `undefined` so they keep their // status instead of throwing a parse error, and skip the after response callbacks since they did not come from FAPI. - const json: FapiResponseJSON | null | undefined = - response.status === 204 - ? null - : await response.json().catch(e => { - if (response.status >= 500) { - return undefined; - } - throw e; - }); + const hasEmptyBody = response.status === 204 || (response.ok && response.headers?.get?.('content-length') === '0'); + const json: FapiResponseJSON | null | undefined = hasEmptyBody + ? null + : await response.json().catch(e => { + if (response.status >= 500) { + return undefined; + } + throw e; + }); const fapiResponse: FapiResponse = Object.assign(response, { payload: json ?? null }); if (!response.ok) { debugLogger.error('request failed', { method, path: requestInit.path, status: response.status }, 'fapiClient'); From 157a3811063cfc6a31fd393688c7f9d87010786b Mon Sep 17 00:00:00 2001 From: seanperez Date: Tue, 27 Oct 2026 16:52:23 -0400 Subject: [PATCH 8/9] fix(clerk-js): make empty successful responses opt-in and read their body A proxy can drop Content-Length, so checking that header missed empty bodies behind @clerk/backend's proxy. And accepting an empty body on every request would let a required resource like the client load from nothing. Only a request with allowEmptyBody now reads its body as text and treats an empty one as a null payload; every other request parses as before. Co-Authored-By: Claude Opus 5.5 --- .../clerk-js-empty-successful-response.md | 2 +- .../src/core/__tests__/fapiClient.test.ts | 31 ++++++++++++++----- packages/clerk-js/src/core/fapiClient.ts | 30 ++++++++++++------ 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/.changeset/clerk-js-empty-successful-response.md b/.changeset/clerk-js-empty-successful-response.md index df39ec00aec..c4937295216 100644 --- a/.changeset/clerk-js-empty-successful-response.md +++ b/.changeset/clerk-js-empty-successful-response.md @@ -2,4 +2,4 @@ '@clerk/clerk-js': patch --- -Accept a successful Frontend API response with an empty body. The request now resolves with a `null` payload and runs its response callbacks, as it does for a `204 No Content` response, instead of failing to parse the empty body as JSON. +Let a Frontend API request opt in to an empty successful response. With the internal `allowEmptyBody` option, an empty body resolves with a `null` payload and runs the response callbacks, as a `204 No Content` response does, even when a proxy drops `Content-Length`. Every other request parses its response as before. diff --git a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts index 693eedad06d..edf156cec98 100644 --- a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts +++ b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts @@ -244,7 +244,7 @@ describe('request', () => { expect(resp.payload).toEqual(null); }); - it('returns a null payload and runs the after response callbacks on a successful response with an empty body', async () => { + it('returns a null payload and runs the after response callbacks on an empty successful body it allows', async () => { const client = createFapiClient(baseFapiClientOptions); const afterResponse = vi.fn(); client.onAfterResponse(afterResponse); @@ -252,30 +252,47 @@ describe('request', () => { Promise.resolve>({ status: 200, ok: true, - headers: { get: (name: string) => (name.toLowerCase() === 'content-length' ? '0' : null) }, + headers: { get: () => null }, + text: () => Promise.resolve(''), json: () => Promise.reject(new SyntaxError('Unexpected end of JSON input')), }), ); - const resp = await client.request({ path: '/' }); + const resp = await client.request({ path: '/' }, { allowEmptyBody: true }); expect(resp.status).toBe(200); expect(resp.payload).toEqual(null); expect(afterResponse).toHaveBeenCalledTimes(1); }); - it('still rejects a successful response whose body is not JSON', async () => { + it('parses a non-empty body when it allows an empty one', async () => { const client = createFapiClient(baseFapiClientOptions); (global.fetch as Mock).mockResolvedValueOnce( Promise.resolve>({ status: 200, ok: true, - headers: { get: (name: string) => (name.toLowerCase() === 'content-length' ? '12' : null) }, - json: () => Promise.reject(new SyntaxError('Unexpected token < in JSON')), + headers: { get: () => null }, + text: () => Promise.resolve('{"response":{"foo":42}}'), + }), + ); + + const resp = await client.request({ path: '/' }, { allowEmptyBody: true }); + + expect(resp.payload).toEqual({ response: { foo: 42 } }); + }); + + it('rejects an empty successful body unless the request allows it', async () => { + const client = createFapiClient(baseFapiClientOptions); + (global.fetch as Mock).mockResolvedValueOnce( + Promise.resolve>({ + status: 200, + ok: true, + headers: { get: (name: string) => (name.toLowerCase() === 'content-length' ? '0' : null) }, + json: () => Promise.reject(new SyntaxError('Unexpected end of JSON input')), }), ); - await expect(client.request({ path: '/foo' })).rejects.toBeInstanceOf(SyntaxError); + await expect(client.request({ path: '/client' })).rejects.toBeInstanceOf(SyntaxError); }); it('returns a null payload and skips the after response callbacks on a non-JSON 5xx response', async () => { diff --git a/packages/clerk-js/src/core/fapiClient.ts b/packages/clerk-js/src/core/fapiClient.ts index 00c3a185325..6de63bd381c 100644 --- a/packages/clerk-js/src/core/fapiClient.ts +++ b/packages/clerk-js/src/core/fapiClient.ts @@ -31,6 +31,11 @@ type FapiQueryStringParameters = { type FapiRequestOptions = { fetchMaxTries?: number; + /** + * Accepts an empty body on a successful response, resolving with a `null` payload, for endpoints that answer with + * no content. Without it, an empty body fails to parse, so a required resource never loads from an empty response. + */ + allowEmptyBody?: boolean; }; export type FapiResponse = Response & { @@ -322,18 +327,23 @@ export function createFapiClient(options: FapiClientOptions): FapiClient { clerkNetworkError(urlStr, e); } - // 204 No Content responses, and successful responses that declare an empty body, have nothing to parse. + // 204 No Content responses do not have a body so we should not try to parse it. // Gateway errors may not be JSON (e.g. a proxy's HTML 502); they resolve to `undefined` so they keep their // status instead of throwing a parse error, and skip the after response callbacks since they did not come from FAPI. - const hasEmptyBody = response.status === 204 || (response.ok && response.headers?.get?.('content-length') === '0'); - const json: FapiResponseJSON | null | undefined = hasEmptyBody - ? null - : await response.json().catch(e => { - if (response.status >= 500) { - return undefined; - } - throw e; - }); + const parseBody = (): Promise | null> => + requestOptions?.allowEmptyBody && response.ok + ? // Proxies can drop Content-Length, so the body itself has to be read to know it's empty. + response.text().then(text => (text === '' ? null : (JSON.parse(text) as FapiResponseJSON))) + : response.json(); + const json: FapiResponseJSON | null | undefined = + response.status === 204 + ? null + : await parseBody().catch(e => { + if (response.status >= 500) { + return undefined; + } + throw e; + }); const fapiResponse: FapiResponse = Object.assign(response, { payload: json ?? null }); if (!response.ok) { debugLogger.error('request failed', { method, path: requestInit.path, status: response.status }, 'fapiClient'); From 81d332c80da448641cb5605533063f2bde07e9f4 Mon Sep 17 00:00:00 2001 From: seanperez Date: Tue, 27 Oct 2026 17:05:53 -0400 Subject: [PATCH 9/9] test(clerk-js): reject a malformed body when an empty one is allowed Co-Authored-By: Claude Opus 5.5 --- .../clerk-js/src/core/__tests__/fapiClient.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts index edf156cec98..d37e9bf3c90 100644 --- a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts +++ b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts @@ -281,6 +281,20 @@ describe('request', () => { expect(resp.payload).toEqual({ response: { foo: 42 } }); }); + it('rejects a malformed non-empty body when it allows an empty one', async () => { + const client = createFapiClient(baseFapiClientOptions); + (global.fetch as Mock).mockResolvedValueOnce( + Promise.resolve>({ + status: 200, + ok: true, + headers: { get: () => null }, + text: () => Promise.resolve(''), + }), + ); + + await expect(client.request({ path: '/' }, { allowEmptyBody: true })).rejects.toBeInstanceOf(SyntaxError); + }); + it('rejects an empty successful body unless the request allows it', async () => { const client = createFapiClient(baseFapiClientOptions); (global.fetch as Mock).mockResolvedValueOnce(