Skip to content

Commit dd3fdc7

Browse files
authored
feat(clerk-expo): Improved offline support for Expo (#4604)
1 parent b34edd2 commit dd3fdc7

File tree

97 files changed

+3259
-243
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

97 files changed

+3259
-243
lines changed

.changeset/cold-avocados-move.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/shared': patch
3+
---
4+
5+
Introduce the `errorToJSON` utility function.

.changeset/mean-trainers-tickle.md

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@clerk/clerk-expo': minor
3+
---
4+
5+
Introduce improved offline support for Expo.
6+
7+
We're introducing an improved offline support for the `@clerk/clerk-expo` package to enhance reliability and user experience. This new improvement allows apps to bootstrap without an internet connection by using cached Clerk resources, ensuring quick initialization.
8+
9+
It solves issues as the following:
10+
11+
- Faster resolution of the `isLoaded` property and the `ClerkLoaded` component, with only a single network fetch attempt, and if it fails, it falls back to the cached resources.
12+
- The `getToken` function of `useAuth` hook now returns a cached token if network errors occur.
13+
- Developers can now catch and handle network errors gracefully in their custom flows, as the errors are no longer muted.
14+
15+
How to use it:
16+
17+
1. Install the `expo-secure-store` package in your project by running:
18+
19+
```bash
20+
npm i expo-secure-store
21+
```
22+
23+
2. Use `import { secureStore } from "@clerk/clerk-expo/secure-store"` to import our implementation of the `SecureStore` API.
24+
3. Pass the `secureStore` in the `__experimental_resourceCache` property of the `ClerkProvider` to enable offline support.
25+
26+
```tsx
27+
import { ClerkProvider, ClerkLoaded } from '@clerk/clerk-expo'
28+
import { Slot } from 'expo-router'
29+
import { tokenCache } from '../token-cache'
30+
import { secureStore } from '@clerk/clerk-expo/secure-store'
31+
32+
export default function RootLayout() {
33+
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!
34+
35+
if (!publishableKey) {
36+
throw new Error('Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to your .env file')
37+
}
38+
39+
return (
40+
<ClerkProvider
41+
publishableKey={publishableKey}
42+
tokenCache={tokenCache}
43+
__experimental_resourceCache={secureStore}
44+
>
45+
<ClerkLoaded>
46+
<Slot />
47+
</ClerkLoaded>
48+
</ClerkProvider>
49+
)
50+
}
51+
```

.changeset/wicked-crabs-bake.md

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
'@clerk/clerk-react': patch
4+
'@clerk/types': patch
5+
---
6+
7+
Introduce a `toJSON()` function on resources.
8+
9+
This change also introduces two new internal methods on the Clerk resource, to be used by the expo package.
10+
11+
- `__internal_getCachedResources()`: (Optional) This function is used to load cached Client and Environment resources if Clerk fails to load them from the Frontend API.
12+
- `__internal_reloadInitialResources()`: This funtion is used to reload the initial resources (Environment/Client) from the Frontend API.

.github/workflows/ci.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,9 @@ jobs:
134134
run: |
135135
if [ "${{ matrix.node-version }}" == "18" ]; then
136136
echo "Running tests on Node 18 only for packages with LTS support."
137-
pnpm turbo test $TURBO_ARGS --filter="@clerk/astro" --filter="@clerk/backend" --filter="@clerk/express" --filter="@clerk/nextjs" --filter="@clerk/clerk-react" --filter="@clerk/clerk-sdk-node" --filter="@clerk/shared" --filter="@clerk/remix" --filter="@clerk/tanstack-start" --filter="@clerk/elements" --filter="@clerk/vue" --filter="@clerk/nuxt"
137+
pnpm turbo test $TURBO_ARGS --filter="@clerk/astro" --filter="@clerk/backend" --filter="@clerk/express" --filter="@clerk/nextjs" --filter="@clerk/clerk-react" --filter="@clerk/clerk-sdk-node" --filter="@clerk/shared" --filter="@clerk/remix" --filter="@clerk/tanstack-start" --filter="@clerk/elements" --filter="@clerk/vue" --filter="@clerk/nuxt" --filter="@clerk/clerk-expo"
138138
else
139-
echo "Running tests for all packages on Node 20."
139+
echo "Running tests for all packages on Node 22."
140140
pnpm turbo test $TURBO_ARGS
141141
fi
142142
env:

packages/clerk-js/src/core/__tests__/clerk.redirects.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jest.mock('../auth/devBrowser', () => ({
2020
}),
2121
}));
2222

23-
Client.getInstance = jest.fn().mockImplementation(() => {
23+
Client.getOrCreateInstance = jest.fn().mockImplementation(() => {
2424
return { fetch: mockClientFetch };
2525
});
2626
Environment.getInstance = jest.fn().mockImplementation(() => {

packages/clerk-js/src/core/__tests__/clerk.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jest.mock('../auth/devBrowser', () => ({
2626
}),
2727
}));
2828

29-
Client.getInstance = jest.fn().mockImplementation(() => {
29+
Client.getOrCreateInstance = jest.fn().mockImplementation(() => {
3030
return { fetch: mockClientFetch };
3131
});
3232
Environment.getInstance = jest.fn().mockImplementation(() => {

packages/clerk-js/src/core/clerk.ts

+43-6
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ import type {
1919
ClerkAPIError,
2020
ClerkAuthenticateWithWeb3Params,
2121
ClerkOptions,
22+
ClientJSONSnapshot,
2223
ClientResource,
2324
CreateOrganizationParams,
2425
CreateOrganizationProps,
2526
CredentialReturn,
2627
DomainOrProxyUrl,
2728
EnvironmentJSON,
29+
EnvironmentJSONSnapshot,
2830
EnvironmentResource,
2931
GoogleOneTapProps,
3032
HandleEmailLinkVerificationParams,
@@ -122,6 +124,7 @@ import {
122124
EmailLinkError,
123125
EmailLinkErrorCode,
124126
Environment,
127+
isClerkRuntimeError,
125128
Organization,
126129
Waitlist,
127130
} from './resources/internal';
@@ -195,6 +198,10 @@ export class Clerk implements ClerkInterface {
195198
#pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null;
196199
#touchThrottledUntil = 0;
197200

201+
public __internal_getCachedResources:
202+
| (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>)
203+
| undefined;
204+
198205
public __internal_createPublicCredentials:
199206
| ((
200207
publicKey: PublicKeyCredentialCreationOptionsWithoutExtensions,
@@ -1496,7 +1503,7 @@ export class Clerk implements ClerkInterface {
14961503
if (!this.client || !this.session) {
14971504
return;
14981505
}
1499-
const newClient = await Client.getInstance().fetch();
1506+
const newClient = await Client.getOrCreateInstance().fetch();
15001507
this.updateClient(newClient);
15011508
if (this.session) {
15021509
return;
@@ -1870,7 +1877,7 @@ export class Clerk implements ClerkInterface {
18701877
});
18711878

18721879
const initClient = () => {
1873-
return Client.getInstance()
1880+
return Client.getOrCreateInstance()
18741881
.fetch()
18751882
.then(res => this.updateClient(res));
18761883
};
@@ -1927,11 +1934,28 @@ export class Clerk implements ClerkInterface {
19271934
return true;
19281935
};
19291936

1937+
private shouldFallbackToCachedResources = (): boolean => {
1938+
return !!this.__internal_getCachedResources;
1939+
};
1940+
19301941
#loadInNonStandardBrowser = async (): Promise<boolean> => {
1931-
const [environment, client] = await Promise.all([
1932-
Environment.getInstance().fetch({ touch: false }),
1933-
Client.getInstance().fetch(),
1934-
]);
1942+
let environment: Environment, client: Client;
1943+
const fetchMaxTries = this.shouldFallbackToCachedResources() ? 1 : undefined;
1944+
try {
1945+
[environment, client] = await Promise.all([
1946+
Environment.getInstance().fetch({ touch: false, fetchMaxTries }),
1947+
Client.getOrCreateInstance().fetch({ fetchMaxTries }),
1948+
]);
1949+
} catch (err) {
1950+
if (isClerkRuntimeError(err) && err.code === 'network_error' && this.shouldFallbackToCachedResources()) {
1951+
const cachedResources = await this.__internal_getCachedResources?.();
1952+
environment = new Environment(cachedResources?.environment);
1953+
Client.clearInstance();
1954+
client = Client.getOrCreateInstance(cachedResources?.client);
1955+
} else {
1956+
throw err;
1957+
}
1958+
}
19351959

19361960
this.updateClient(client);
19371961
this.updateEnvironment(environment);
@@ -1945,6 +1969,19 @@ export class Clerk implements ClerkInterface {
19451969
return true;
19461970
};
19471971

1972+
// This is used by @clerk/clerk-expo
1973+
__internal_reloadInitialResources = async (): Promise<void> => {
1974+
const [environment, client] = await Promise.all([
1975+
Environment.getInstance().fetch({ touch: false, fetchMaxTries: 1 }),
1976+
Client.getOrCreateInstance().fetch({ fetchMaxTries: 1 }),
1977+
]);
1978+
1979+
this.updateClient(client);
1980+
this.updateEnvironment(environment);
1981+
1982+
this.#emit();
1983+
};
1984+
19481985
#defaultSession = (client: ClientResource): ActiveSessionResource | null => {
19491986
if (client.lastActiveSessionId) {
19501987
const lastActiveSession = client.activeSessions.find(s => s.id === client.lastActiveSessionId);

packages/clerk-js/src/core/fapiClient.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ type FapiQueryStringParameters = {
2525
rotating_token_nonce?: string;
2626
};
2727

28+
type FapiRequestOptions = {
29+
fetchMaxTries?: number;
30+
};
31+
2832
export type FapiResponse<T> = Response & {
2933
payload: FapiResponseJSON<T> | null;
3034
};
@@ -54,7 +58,7 @@ export interface FapiClient {
5458

5559
onBeforeRequest(callback: FapiRequestCallback<unknown>): void;
5660

57-
request<T>(requestInit: FapiRequestInit): Promise<FapiResponse<T>>;
61+
request<T>(requestInit: FapiRequestInit, options?: FapiRequestOptions): Promise<FapiResponse<T>>;
5862
}
5963

6064
// List of paths that should not receive the session ID parameter in the URL
@@ -172,7 +176,7 @@ export function createFapiClient(clerkInstance: Clerk): FapiClient {
172176
});
173177
}
174178

175-
async function request<T>(_requestInit: FapiRequestInit): Promise<FapiResponse<T>> {
179+
async function request<T>(_requestInit: FapiRequestInit, options?: FapiRequestOptions): Promise<FapiResponse<T>> {
176180
const requestInit = { ..._requestInit };
177181
const { method = 'GET', body } = requestInit;
178182

@@ -225,7 +229,7 @@ export function createFapiClient(clerkInstance: Clerk): FapiClient {
225229

226230
try {
227231
if (beforeRequestCallbacksResult) {
228-
const maxTries = isBrowserOnline() ? 4 : 11;
232+
const maxTries = options?.fetchMaxTries ?? (isBrowserOnline() ? 4 : 11);
229233
response =
230234
// retry only on GET requests for safety
231235
overwrittenRequestMethod === 'GET'

packages/clerk-js/src/core/fraudProtection.test.ts

+8-8
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@ describe('FraudProtectionService', () => {
3333
};
3434

3535
mockClient = {
36-
getInstance: () => {
36+
getOrCreateInstance: () => {
3737
return mockClientInstance;
3838
},
3939
} as any as typeof Client;
4040

41-
mockClerk = { client: mockClient.getInstance() } as any as Clerk;
41+
mockClerk = { client: mockClient.getOrCreateInstance() } as any as Clerk;
4242

4343
sut = new FraudProtection(mockClient, MockCaptchaChallenge as any);
4444
});
@@ -54,7 +54,7 @@ describe('FraudProtectionService', () => {
5454

5555
// only one will need to call the captcha as the other will be blocked
5656
expect(mockManaged).toHaveBeenCalledTimes(0);
57-
expect(mockClient.getInstance().sendCaptchaToken).toHaveBeenCalledTimes(0);
57+
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(0);
5858
expect(fn1).toHaveBeenCalledTimes(1);
5959
});
6060

@@ -67,7 +67,7 @@ describe('FraudProtectionService', () => {
6767
const fn1res = sut.execute(mockClerk, fn1);
6868
expect(fn1res).rejects.toEqual(unrelatedError);
6969
expect(mockManaged).toHaveBeenCalledTimes(0);
70-
expect(mockClient.getInstance().sendCaptchaToken).toHaveBeenCalledTimes(0);
70+
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(0);
7171
expect(fn1).toHaveBeenCalledTimes(1);
7272
});
7373

@@ -87,7 +87,7 @@ describe('FraudProtectionService', () => {
8787

8888
// only one will need to call the captcha as the other will be blocked
8989
expect(mockManaged).toHaveBeenCalledTimes(1);
90-
expect(mockClient.getInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
90+
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
9191
expect(fn1).toHaveBeenCalledTimes(2);
9292
});
9393

@@ -107,7 +107,7 @@ describe('FraudProtectionService', () => {
107107

108108
// captcha will only be called once
109109
expect(mockManaged).toHaveBeenCalledTimes(1);
110-
expect(mockClient.getInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
110+
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
111111
// but all failed requests will be retried
112112
expect(fn1).toHaveBeenCalledTimes(2);
113113
expect(fn2).toHaveBeenCalledTimes(2);
@@ -134,7 +134,7 @@ describe('FraudProtectionService', () => {
134134
await Promise.all([fn1res, fn2res]);
135135

136136
expect(mockManaged).toHaveBeenCalledTimes(1);
137-
expect(mockClient.getInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
137+
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
138138
expect(fn1).toHaveBeenCalledTimes(2);
139139
expect(fn2).toHaveBeenCalledTimes(1);
140140
});
@@ -167,7 +167,7 @@ describe('FraudProtectionService', () => {
167167
await Promise.all([fn2res, fn3res]);
168168

169169
expect(mockManaged).toHaveBeenCalledTimes(1);
170-
expect(mockClient.getInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
170+
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
171171

172172
expect(fn1).toHaveBeenCalledTimes(2);
173173
expect(fn2).toHaveBeenCalledTimes(2);

packages/clerk-js/src/core/fraudProtection.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export class FraudProtection {
5050
const captchaParams = await this.managedChallenge(clerk);
5151

5252
try {
53-
await this.client.getInstance().sendCaptchaToken(captchaParams);
53+
await this.client.getOrCreateInstance().sendCaptchaToken(captchaParams);
5454
} finally {
5555
// Resolve the exception placeholder promise so that other exceptions can be handled
5656
resolve();

packages/clerk-js/src/core/resources/AuthConfig.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AuthConfigJSON, AuthConfigResource } from '@clerk/types';
1+
import type { AuthConfigJSON, AuthConfigJSONSnapshot, AuthConfigResource } from '@clerk/types';
22

33
import { unixEpochToDate } from '../../utils/date';
44
import { BaseResource } from './internal';
@@ -17,4 +17,13 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
1717
this.claimedAt = data?.claimed_at ? unixEpochToDate(data.claimed_at) : null;
1818
return this;
1919
}
20+
21+
public toJSON(): AuthConfigJSONSnapshot {
22+
return {
23+
object: 'auth_config',
24+
id: this.id || '',
25+
single_session_mode: this.singleSessionMode,
26+
claimed_at: this.claimedAt ? this.claimedAt.getTime() : null,
27+
};
28+
}
2029
}

packages/clerk-js/src/core/resources/Base.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import { FraudProtection } from '../fraudProtection';
99
import type { Clerk } from './internal';
1010
import { ClerkAPIResponseError, ClerkRuntimeError, Client } from './internal';
1111

12-
export type BaseFetchOptions = ClerkResourceReloadParams & { forceUpdateClient?: boolean };
12+
export type BaseFetchOptions = ClerkResourceReloadParams & {
13+
forceUpdateClient?: boolean;
14+
fetchMaxTries?: number;
15+
};
1316

1417
export type BaseMutateParams = {
1518
action?: string;
@@ -78,9 +81,10 @@ export abstract class BaseResource {
7881
}
7982

8083
let fapiResponse: FapiResponse<J>;
84+
const { fetchMaxTries } = opts;
8185

8286
try {
83-
fapiResponse = await BaseResource.fapiClient.request<J>(requestInit);
87+
fapiResponse = await BaseResource.fapiClient.request<J>(requestInit, { fetchMaxTries });
8488
} catch (e) {
8589
// TODO: This should be the default behavior in the next major version, as long as we have a way to handle the requests more gracefully when offline
8690
if (this.shouldRethrowOfflineNetworkErrors()) {
@@ -144,7 +148,7 @@ export abstract class BaseResource {
144148
const client = responseJSON.client || responseJSON.meta?.client;
145149

146150
if (client && BaseResource.clerk) {
147-
BaseResource.clerk.updateClient(Client.getInstance().fromJSON(client));
151+
BaseResource.clerk.updateClient(Client.getOrCreateInstance().fromJSON(client));
148152
}
149153
}
150154

0 commit comments

Comments
 (0)