Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clerk-js-empty-successful-response.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/clerk-js-finish-failed-native-load.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/react-replay-after-finished-native-load.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/react': patch
---

When a Clerk instance passed to `<ClerkProvider>` on iOS and Android fails to load and later finishes loading, `<ClerkProvider>` now reports it as loaded and runs the calls it queued while loading, without needing a new instance.
115 changes: 115 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
});
});

Expand Down
65 changes: 65 additions & 0 deletions packages/clerk-js/src/core/__tests__/fapiClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RecursivePartial<Response>>({
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<RecursivePartial<Response>>({
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 } });
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<RecursivePartial<Response>>({
status: 200,
ok: true,
headers: { get: () => null },
text: () => Promise.resolve('<html>'),
}),
);

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<RecursivePartial<Response>>({
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();
Expand Down
61 changes: 41 additions & 20 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -680,33 +681,43 @@ 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
throw error;
}
};

#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));
}
Expand Down Expand Up @@ -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<void> => {
const [environment, client] = await Promise.all([
Environment.getInstance().fetch({ touch: false, fetchMaxTries: 1 }),
Expand All @@ -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') {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
this.#finishLoad();
this.#publicEventBus.emit(clerkEvents.Status, 'ready');
} else if (this.#status === 'loading') {
this.#initialResourcesReloadedDuringLoad = true;
}

this.#emit();
};

Expand Down
12 changes: 11 additions & 1 deletion packages/clerk-js/src/core/fapiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = Response & {
Expand Down Expand Up @@ -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<FapiResponseJSON<T> | 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<T>)))
: response.json();
const json: FapiResponseJSON<T> | null | undefined =
response.status === 204
? null
: await response.json().catch(e => {
: await parseBody().catch(e => {
if (response.status >= 500) {
return undefined;
}
Expand Down
Loading
Loading