Skip to content
Merged
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/tidy-expo-retries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo': patch
---

Prevent Expo apps from accumulating duplicate initialization requests while the Clerk API is unavailable or the app reloads.
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { Clerk } from '@clerk/clerk-js';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { ClerkRuntimeError } from '@clerk/shared/error';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';

import type { TokenCache } from '../../../cache/types';
import { CLERK_CLIENT_JWT_KEY } from '../../../constants';

const mocks = vi.hoisted(() => {
return {
constructorSpy: vi.fn(),
requestInitialResources: vi.fn<() => Promise<void>>(),
};
});

Expand All @@ -31,10 +33,17 @@ class MockClerk {
}

addListener = vi.fn();
__internal_reloadInitialResources = () => mocks.requestInitialResources();
__internal_getCachedResources?: () => Promise<{ client: unknown; environment: unknown }>;
__internal_onBeforeRequest = vi.fn();
__internal_onAfterResponse = vi.fn();
}

const createUnavailableResourceCache = () => ({
get: () => Promise.resolve(null),
set: () => Promise.resolve(),
});

const loadCreateClerkInstance = async () => {
const mod = await import('../createClerkInstance');
return mod.createClerkInstance;
Expand All @@ -44,6 +53,7 @@ describe('createClerkInstance', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mocks.requestInitialResources.mockResolvedValue(undefined);
});

test('passes proxyUrl to the native Clerk constructor', async () => {
Expand Down Expand Up @@ -384,4 +394,108 @@ describe('createClerkInstance', () => {
expect(initialTokenCache.saveToken).not.toHaveBeenCalled();
expect(latestTokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'fresh-token');
});

describe('initial resource recovery', () => {
const setupUnavailableResources = async () => {
const createClerkInstance = await loadCreateClerkInstance();
const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk);
const clerk = getClerkInstance({
publishableKey: 'pk_test_123',
__experimental_resourceCache: createUnavailableResourceCache,
}) as unknown as MockClerk;

return { clerk, getClerkInstance };
};

beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

test('keeps one FAPI recovery attempt active across repeated cache misses', async () => {
mocks.requestInitialResources.mockImplementation(() => new Promise(() => {}));

const { clerk } = await setupUnavailableResources();

const cachedResources = await Promise.all([
clerk.__internal_getCachedResources?.(),
clerk.__internal_getCachedResources?.(),
]);

expect(cachedResources).toEqual([
{ client: expect.any(Object), environment: expect.any(Object) },
{ client: expect.any(Object), environment: expect.any(Object) },
]);

await vi.advanceTimersByTimeAsync(3_000);

expect(mocks.requestInitialResources).toHaveBeenCalledTimes(1);
});

test('does not retry a failed recovery after the instance is replaced', async () => {
mocks.requestInitialResources.mockRejectedValue(
new ClerkRuntimeError('FAPI unavailable', { code: 'network_error' }),
);

const { clerk, getClerkInstance } = await setupUnavailableResources();

await clerk.__internal_getCachedResources?.();
await vi.advanceTimersByTimeAsync(3_000);
expect(mocks.requestInitialResources).toHaveBeenCalledTimes(1);

getClerkInstance({ publishableKey: 'pk_test_second' });
await vi.advanceTimersByTimeAsync(30_000);

expect(mocks.requestInitialResources).toHaveBeenCalledTimes(1);
});

test('does not start another FAPI recovery while one is still pending', async () => {
mocks.requestInitialResources.mockImplementation(() => new Promise(() => {}));

const { clerk } = await setupUnavailableResources();

await clerk.__internal_getCachedResources?.();
await vi.advanceTimersByTimeAsync(3_000);
await clerk.__internal_getCachedResources?.();
await vi.advanceTimersByTimeAsync(3_000);

expect(mocks.requestInitialResources).toHaveBeenCalledTimes(1);
});

test('backs off failed FAPI recovery attempts without waiting more than 30 seconds', async () => {
mocks.requestInitialResources.mockRejectedValue(
new ClerkRuntimeError('FAPI unavailable', { code: 'network_error' }),
);

const { clerk } = await setupUnavailableResources();

await clerk.__internal_getCachedResources?.();
await vi.advanceTimersByTimeAsync(3_000);
expect(mocks.requestInitialResources).toHaveBeenCalledTimes(1);

const retryDelays = [2_000, 4_000, 8_000, 16_000, 30_000, 30_000];
for (const [index, retryDelay] of retryDelays.entries()) {
await vi.advanceTimersByTimeAsync(retryDelay);
expect(mocks.requestInitialResources).toHaveBeenCalledTimes(index + 2);
}
});

test('stops recovering after the initial resources load', async () => {
mocks.requestInitialResources.mockResolvedValue(undefined);

const { clerk } = await setupUnavailableResources();

await clerk.__internal_getCachedResources?.();
await vi.advanceTimersByTimeAsync(3_000);
expect(mocks.requestInitialResources).toHaveBeenCalledTimes(1);

await clerk.__internal_getCachedResources?.();
await vi.advanceTimersByTimeAsync(30_000);

expect(mocks.requestInitialResources).toHaveBeenCalledTimes(1);
});
});
});
73 changes: 64 additions & 9 deletions packages/expo/src/provider/singleton/createClerkInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ type ResolvedClerkRuntimeOptions = Omit<ClerkRuntimeOptions, 'publishableKey'> &
publishableKey: string;
};

const RESOURCE_RETRY_MAX_DELAY_MS = 30_000;
const RESOURCE_RETRY_MAX_EXPONENT = 4;

function hasOwnOption<Key extends keyof BuildClerkOptions>(
options: BuildClerkOptions | undefined,
key: Key,
Expand All @@ -53,6 +56,7 @@ function hasOwnOption<Key extends keyof BuildClerkOptions>(

let __internal_clerk: HeadlessBrowserClerk | BrowserClerk | undefined;
let __internal_clerkOptions: ClerkRuntimeOptions | undefined;
let __internal_cancelResourceRetries: (() => void) | undefined;
// Token IO can change without recreating the native singleton.
let __internal_tokenCache: TokenCache = MemoryTokenCache;

Expand Down Expand Up @@ -110,6 +114,9 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
if (!__internal_clerk || hasConfigChanged) {
assertValidProxyUrl(proxyUrl);

__internal_cancelResourceRetries?.();
__internal_cancelResourceRetries = undefined;

if (hasConfigChanged) {
void __internal_tokenCache.clearToken?.(CLERK_CLIENT_JWT_KEY);
}
Expand All @@ -118,7 +125,8 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
const saveToken = (key: string, token: string) => __internal_tokenCache.saveToken(key, token);

__internal_clerkOptions = { publishableKey, proxyUrl, domain };
__internal_clerk = new ClerkClass(publishableKey, { proxyUrl, domain }) as unknown as BrowserClerk;
const clerk = new ClerkClass(publishableKey, { proxyUrl, domain }) as unknown as BrowserClerk;
__internal_clerk = clerk;

if (Platform.OS === 'ios' || Platform.OS === 'android') {
// @ts-expect-error - This is an internal API
Expand Down Expand Up @@ -162,17 +170,64 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
const isClerkNetworkError = (err: unknown): boolean => isClerkRuntimeError(err) && err.code === 'network_error';

if (createResourceCache) {
const retryInitilizeResourcesFromFAPI = async () => {
let resourceRetryTimer: ReturnType<typeof setTimeout> | undefined;
let resourceRetryCancelled = false;
let resourceRetryInFlight = false;
let resourceRetryFailureCount = 0;
let initialResourcesLoaded = false;

__internal_cancelResourceRetries = () => {
resourceRetryCancelled = true;
if (resourceRetryTimer !== undefined) {
clearTimeout(resourceRetryTimer);
resourceRetryTimer = undefined;
}
};

const scheduleResourceRetry = (timeout: number) => {
if (
resourceRetryCancelled ||
initialResourcesLoaded ||
resourceRetryTimer !== undefined ||
resourceRetryInFlight
) {
return;
}

resourceRetryTimer = setTimeout(() => {
resourceRetryTimer = undefined;
if (resourceRetryCancelled || initialResourcesLoaded) {
return;
}
void retryInitializeResourcesFromFAPI();
}, timeout);
};

const retryInitializeResourcesFromFAPI = async () => {
if (resourceRetryCancelled || initialResourcesLoaded || resourceRetryInFlight) {
return;
}

resourceRetryInFlight = true;
let nextRetryTimeout: number | undefined;
try {
await __internal_clerk?.__internal_reloadInitialResources();
await clerk.__internal_reloadInitialResources();
initialResourcesLoaded = true;
} catch (err: unknown) {
// Retry after 3 seconds if the error is a network error or a 5xx error
if (isClerkNetworkError(err) || !is4xxError(err)) {
// Retry after 2 seconds if the error is a network error
// Retry after 10 seconds if the error is a 5xx FAPI error
const timeout = isClerkNetworkError(err) ? 2000 : 10000;
setTimeout(() => void retryInitilizeResourcesFromFAPI(), timeout);
const initialRetryTimeout = isClerkNetworkError(err) ? 2000 : 10000;
nextRetryTimeout = Math.min(
initialRetryTimeout * 2 ** resourceRetryFailureCount,
RESOURCE_RETRY_MAX_DELAY_MS,
);
resourceRetryFailureCount = Math.min(resourceRetryFailureCount + 1, RESOURCE_RETRY_MAX_EXPONENT);
Comment on lines 217 to +223

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up idea: @clerk/shared/retry (already used elsewhere in the monorepo for this kind of backoff) could replace this and gives us jitter for free. It needs to have an option to cancel first... happy to help add that in a follow-up PR

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good!

}
} finally {
resourceRetryInFlight = false;
}

if (nextRetryTimeout !== undefined) {
scheduleResourceRetry(nextRetryTimeout);
}
};

Expand Down Expand Up @@ -210,7 +265,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
if (!environment || !client) {
environment = DUMMY_CLERK_ENVIRONMENT_RESOURCE;
client = DUMMY_CLERK_CLIENT_RESOURCE;
setTimeout(() => void retryInitilizeResourcesFromFAPI(), 3000);
scheduleResourceRetry(3000);
}
return { client, environment };
};
Expand Down
Loading