Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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-finish-failed-native-load.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

In `@clerk/expo` apps on iOS and Android, Clerk now finishes loading once its requests succeed again after a failed load, such as while Clerk rejects an outdated app version. Previously it stayed failed until the app restarted.
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
---

In `@clerk/expo` apps on iOS and Android, when Clerk fails to load and later finishes loading, `<ClerkProvider>` now reports Clerk as loaded and runs the calls made while it was loading. Previously `isLoaded` stayed `false` until the app restarted.
4 changes: 2 additions & 2 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"files": [
{ "path": "./dist/clerk.js", "maxSize": "556KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "83KB" },
{ "path": "./dist/clerk.js", "maxSize": "557KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "84KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "128KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "324KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "84KB" },
Expand Down
149 changes: 149 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,155 @@ 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([]);
});
});
});

describe('__internal_reloadInitialResources after a failed standard browser load', () => {
const unsupportedAppVersion = () =>
new ClerkAPIResponseError('Unsupported app version', { data: [], status: 403 });

it('does not finish the failed load', async () => {
const sut = new Clerk(productionPublishableKey);
mockClientFetch.mockRejectedValueOnce(unsupportedAppVersion());
await expect(sut.load()).rejects.toBeInstanceOf(ClerkAPIResponseError);
const statuses: string[] = [];
sut.on('status', status => statuses.push(status));
mockClientFetch.mockResolvedValueOnce({ signedInSessions: [] });

await sut.__internal_reloadInitialResources();

expect(sut.status).toBe('error');
expect(statuses).toEqual([]);
});

it('does not finish a load that fails after a reload succeeded during it', async () => {
const sut = new Clerk(productionPublishableKey);
const loadClientFetch = createDeferredPromise();
mockClientFetch.mockReturnValueOnce(loadClientFetch.promise);

const load = sut.load();
await vi.waitFor(() => expect(mockClientFetch).toHaveBeenCalledTimes(1));
mockClientFetch.mockResolvedValueOnce({ signedInSessions: [] });
await sut.__internal_reloadInitialResources();
loadClientFetch.reject(unsupportedAppVersion());

await expect(load).rejects.toBeInstanceOf(ClerkAPIResponseError);
expect(sut.status).toBe('error');
});
});
});

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.
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 @@ -280,6 +280,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 @@ -681,33 +682,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, this.frontendApi);
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.#options.standardBrowser && 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, this.frontendApi);
debugLogger.info('load() complete', {}, 'clerk');
};

#isCombinedSignInOrUpFlow(): boolean {
return Boolean(!this.#options.signUpUrl && this.#options.signInUrl && !isAbsoluteUrl(this.#options.signInUrl));
}
Expand Down Expand Up @@ -3719,7 +3730,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 @@ -3729,6 +3741,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.#options.standardBrowser && this.#status === 'error') {
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 @@ -326,10 +331,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