diff --git a/.changeset/clerk-js-empty-successful-response.md b/.changeset/clerk-js-empty-successful-response.md new file mode 100644 index 00000000000..c4937295216 --- /dev/null +++ b/.changeset/clerk-js-empty-successful-response.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +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/.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/.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/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 189f3ab911f..52096724963 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -1106,6 +1106,121 @@ 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('finishes a load that fails after a reload succeeded while it was running', async () => { + const sut = new Clerk(productionPublishableKey); + const firstClientFetch = createDeferredPromise(); + 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'); + }); + + 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: [] }); + 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/__tests__/fapiClient.test.ts b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts index aeb7273a1d0..d37e9bf3c90 100644 --- a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts +++ b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts @@ -244,6 +244,71 @@ describe('request', () => { expect(resp.payload).toEqual(null); }); + 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); + (global.fetch as Mock).mockResolvedValueOnce( + Promise.resolve>({ + status: 200, + ok: true, + headers: { get: () => null }, + text: () => Promise.resolve(''), + json: () => Promise.reject(new SyntaxError('Unexpected end of JSON input')), + }), + ); + + const resp = await client.request({ path: '/' }, { allowEmptyBody: true }); + + expect(resp.status).toBe(200); + expect(resp.payload).toEqual(null); + expect(afterResponse).toHaveBeenCalledTimes(1); + }); + + 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: () => null }, + text: () => Promise.resolve('{"response":{"foo":42}}'), + }), + ); + + const resp = await client.request({ path: '/' }, { allowEmptyBody: true }); + + 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( + 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: '/client' })).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/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b5ca5d2e97c..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 = {}; @@ -680,26 +681,14 @@ 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) { + // 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 @@ -707,6 +696,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,7 +3695,8 @@ 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 [environment, client] = await Promise.all([ Environment.getInstance().fetch({ touch: false, fetchMaxTries: 1 }), @@ -3694,6 +3706,15 @@ export class Clerk implements ClerkInterface { this.updateClient(client); this.updateEnvironment(environment); + // 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(); }; diff --git a/packages/clerk-js/src/core/fapiClient.ts b/packages/clerk-js/src/core/fapiClient.ts index 81203f29e8b..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 & { @@ -325,10 +330,15 @@ export function createFapiClient(options: FapiClientOptions): FapiClient { // 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 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 response.json().catch(e => { + : await parseBody().catch(e => { if (response.status >= 500) { return undefined; } diff --git a/packages/react/src/__tests__/isomorphicClerk.test.ts b/packages/react/src/__tests__/isomorphicClerk.test.ts index 43c1e8a6e84..ebaf77e3dd5 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,150 @@ describe('isomorphicClerk', () => { expect(handleResourceCallback).toHaveBeenCalledWith(signInOrUp, params, customNavigate); }); + describe('a headless Clerk instance whose load failed', () => { + function createFailingHeadlessClerk({ finishesBeforeLoadRejects = false, throwingListener = false } = {}) { + 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'); + if (finishesBeforeLoadRejects) { + eventBus.emit(clerkEvents.Status, 'ready'); + } + return Promise.reject(new Error('Unsupported app version')); + }), + on: eventBus.on, + off: eventBus.off, + addListener: vi.fn(() => { + if (throwingListener) { + throw new Error('listener failed'); + } + }), + 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('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.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()); + 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..ace749035d0 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,12 +372,56 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } this.#eventBus.emit(clerkEvents.Status, 'error'); this.emitLoaded(); + if (loadFailed) { + 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 { + // A reload can finish the load before this runs. + if (clerk.loaded) { + this.replayFinishedLoad(clerk); + return; + } + if (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.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; }