Skip to content

Commit

Permalink
feat(clerk-expo): Improved offline support for Expo (#4604)
Browse files Browse the repository at this point in the history
  • Loading branch information
anagstef authored Dec 12, 2024
1 parent b34edd2 commit dd3fdc7
Show file tree
Hide file tree
Showing 97 changed files with 3,259 additions and 243 deletions.
5 changes: 5 additions & 0 deletions .changeset/cold-avocados-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Introduce the `errorToJSON` utility function.
51 changes: 51 additions & 0 deletions .changeset/mean-trainers-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
'@clerk/clerk-expo': minor
---

Introduce improved offline support for Expo.

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.

It solves issues as the following:

- 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.
- The `getToken` function of `useAuth` hook now returns a cached token if network errors occur.
- Developers can now catch and handle network errors gracefully in their custom flows, as the errors are no longer muted.

How to use it:

1. Install the `expo-secure-store` package in your project by running:

```bash
npm i expo-secure-store
```

2. Use `import { secureStore } from "@clerk/clerk-expo/secure-store"` to import our implementation of the `SecureStore` API.
3. Pass the `secureStore` in the `__experimental_resourceCache` property of the `ClerkProvider` to enable offline support.
```tsx
import { ClerkProvider, ClerkLoaded } from '@clerk/clerk-expo'
import { Slot } from 'expo-router'
import { tokenCache } from '../token-cache'
import { secureStore } from '@clerk/clerk-expo/secure-store'
export default function RootLayout() {
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!
if (!publishableKey) {
throw new Error('Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to your .env file')
}
return (
<ClerkProvider
publishableKey={publishableKey}
tokenCache={tokenCache}
__experimental_resourceCache={secureStore}
>
<ClerkLoaded>
<Slot />
</ClerkLoaded>
</ClerkProvider>
)
}
```
12 changes: 12 additions & 0 deletions .changeset/wicked-crabs-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@clerk/clerk-js': patch
'@clerk/clerk-react': patch
'@clerk/types': patch
---

Introduce a `toJSON()` function on resources.

This change also introduces two new internal methods on the Clerk resource, to be used by the expo package.

- `__internal_getCachedResources()`: (Optional) This function is used to load cached Client and Environment resources if Clerk fails to load them from the Frontend API.
- `__internal_reloadInitialResources()`: This funtion is used to reload the initial resources (Environment/Client) from the Frontend API.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,9 @@ jobs:
run: |
if [ "${{ matrix.node-version }}" == "18" ]; then
echo "Running tests on Node 18 only for packages with LTS support."
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"
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"
else
echo "Running tests for all packages on Node 20."
echo "Running tests for all packages on Node 22."
pnpm turbo test $TURBO_ARGS
fi
env:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jest.mock('../auth/devBrowser', () => ({
}),
}));

Client.getInstance = jest.fn().mockImplementation(() => {
Client.getOrCreateInstance = jest.fn().mockImplementation(() => {
return { fetch: mockClientFetch };
});
Environment.getInstance = jest.fn().mockImplementation(() => {
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jest.mock('../auth/devBrowser', () => ({
}),
}));

Client.getInstance = jest.fn().mockImplementation(() => {
Client.getOrCreateInstance = jest.fn().mockImplementation(() => {
return { fetch: mockClientFetch };
});
Environment.getInstance = jest.fn().mockImplementation(() => {
Expand Down
49 changes: 43 additions & 6 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ import type {
ClerkAPIError,
ClerkAuthenticateWithWeb3Params,
ClerkOptions,
ClientJSONSnapshot,
ClientResource,
CreateOrganizationParams,
CreateOrganizationProps,
CredentialReturn,
DomainOrProxyUrl,
EnvironmentJSON,
EnvironmentJSONSnapshot,
EnvironmentResource,
GoogleOneTapProps,
HandleEmailLinkVerificationParams,
Expand Down Expand Up @@ -122,6 +124,7 @@ import {
EmailLinkError,
EmailLinkErrorCode,
Environment,
isClerkRuntimeError,
Organization,
Waitlist,
} from './resources/internal';
Expand Down Expand Up @@ -195,6 +198,10 @@ export class Clerk implements ClerkInterface {
#pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null;
#touchThrottledUntil = 0;

public __internal_getCachedResources:
| (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>)
| undefined;

public __internal_createPublicCredentials:
| ((
publicKey: PublicKeyCredentialCreationOptionsWithoutExtensions,
Expand Down Expand Up @@ -1496,7 +1503,7 @@ export class Clerk implements ClerkInterface {
if (!this.client || !this.session) {
return;
}
const newClient = await Client.getInstance().fetch();
const newClient = await Client.getOrCreateInstance().fetch();
this.updateClient(newClient);
if (this.session) {
return;
Expand Down Expand Up @@ -1870,7 +1877,7 @@ export class Clerk implements ClerkInterface {
});

const initClient = () => {
return Client.getInstance()
return Client.getOrCreateInstance()
.fetch()
.then(res => this.updateClient(res));
};
Expand Down Expand Up @@ -1927,11 +1934,28 @@ export class Clerk implements ClerkInterface {
return true;
};

private shouldFallbackToCachedResources = (): boolean => {
return !!this.__internal_getCachedResources;
};

#loadInNonStandardBrowser = async (): Promise<boolean> => {
const [environment, client] = await Promise.all([
Environment.getInstance().fetch({ touch: false }),
Client.getInstance().fetch(),
]);
let environment: Environment, client: Client;
const fetchMaxTries = this.shouldFallbackToCachedResources() ? 1 : undefined;
try {
[environment, client] = await Promise.all([
Environment.getInstance().fetch({ touch: false, fetchMaxTries }),
Client.getOrCreateInstance().fetch({ fetchMaxTries }),
]);
} catch (err) {
if (isClerkRuntimeError(err) && err.code === 'network_error' && this.shouldFallbackToCachedResources()) {
const cachedResources = await this.__internal_getCachedResources?.();
environment = new Environment(cachedResources?.environment);
Client.clearInstance();
client = Client.getOrCreateInstance(cachedResources?.client);
} else {
throw err;
}
}

this.updateClient(client);
this.updateEnvironment(environment);
Expand All @@ -1945,6 +1969,19 @@ export class Clerk implements ClerkInterface {
return true;
};

// This is used by @clerk/clerk-expo
__internal_reloadInitialResources = async (): Promise<void> => {
const [environment, client] = await Promise.all([
Environment.getInstance().fetch({ touch: false, fetchMaxTries: 1 }),
Client.getOrCreateInstance().fetch({ fetchMaxTries: 1 }),
]);

this.updateClient(client);
this.updateEnvironment(environment);

this.#emit();
};

#defaultSession = (client: ClientResource): ActiveSessionResource | null => {
if (client.lastActiveSessionId) {
const lastActiveSession = client.activeSessions.find(s => s.id === client.lastActiveSessionId);
Expand Down
10 changes: 7 additions & 3 deletions packages/clerk-js/src/core/fapiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ type FapiQueryStringParameters = {
rotating_token_nonce?: string;
};

type FapiRequestOptions = {
fetchMaxTries?: number;
};

export type FapiResponse<T> = Response & {
payload: FapiResponseJSON<T> | null;
};
Expand Down Expand Up @@ -54,7 +58,7 @@ export interface FapiClient {

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

request<T>(requestInit: FapiRequestInit): Promise<FapiResponse<T>>;
request<T>(requestInit: FapiRequestInit, options?: FapiRequestOptions): Promise<FapiResponse<T>>;
}

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

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

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

try {
if (beforeRequestCallbacksResult) {
const maxTries = isBrowserOnline() ? 4 : 11;
const maxTries = options?.fetchMaxTries ?? (isBrowserOnline() ? 4 : 11);
response =
// retry only on GET requests for safety
overwrittenRequestMethod === 'GET'
Expand Down
16 changes: 8 additions & 8 deletions packages/clerk-js/src/core/fraudProtection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@ describe('FraudProtectionService', () => {
};

mockClient = {
getInstance: () => {
getOrCreateInstance: () => {
return mockClientInstance;
},
} as any as typeof Client;

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

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

// only one will need to call the captcha as the other will be blocked
expect(mockManaged).toHaveBeenCalledTimes(0);
expect(mockClient.getInstance().sendCaptchaToken).toHaveBeenCalledTimes(0);
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(0);
expect(fn1).toHaveBeenCalledTimes(1);
});

Expand All @@ -67,7 +67,7 @@ describe('FraudProtectionService', () => {
const fn1res = sut.execute(mockClerk, fn1);
expect(fn1res).rejects.toEqual(unrelatedError);
expect(mockManaged).toHaveBeenCalledTimes(0);
expect(mockClient.getInstance().sendCaptchaToken).toHaveBeenCalledTimes(0);
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(0);
expect(fn1).toHaveBeenCalledTimes(1);
});

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

// only one will need to call the captcha as the other will be blocked
expect(mockManaged).toHaveBeenCalledTimes(1);
expect(mockClient.getInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
expect(fn1).toHaveBeenCalledTimes(2);
});

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

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

expect(mockManaged).toHaveBeenCalledTimes(1);
expect(mockClient.getInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
expect(fn1).toHaveBeenCalledTimes(2);
expect(fn2).toHaveBeenCalledTimes(1);
});
Expand Down Expand Up @@ -167,7 +167,7 @@ describe('FraudProtectionService', () => {
await Promise.all([fn2res, fn3res]);

expect(mockManaged).toHaveBeenCalledTimes(1);
expect(mockClient.getInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);
expect(mockClient.getOrCreateInstance().sendCaptchaToken).toHaveBeenCalledTimes(1);

expect(fn1).toHaveBeenCalledTimes(2);
expect(fn2).toHaveBeenCalledTimes(2);
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/core/fraudProtection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class FraudProtection {
const captchaParams = await this.managedChallenge(clerk);

try {
await this.client.getInstance().sendCaptchaToken(captchaParams);
await this.client.getOrCreateInstance().sendCaptchaToken(captchaParams);
} finally {
// Resolve the exception placeholder promise so that other exceptions can be handled
resolve();
Expand Down
11 changes: 10 additions & 1 deletion packages/clerk-js/src/core/resources/AuthConfig.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AuthConfigJSON, AuthConfigResource } from '@clerk/types';
import type { AuthConfigJSON, AuthConfigJSONSnapshot, AuthConfigResource } from '@clerk/types';

import { unixEpochToDate } from '../../utils/date';
import { BaseResource } from './internal';
Expand All @@ -17,4 +17,13 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
this.claimedAt = data?.claimed_at ? unixEpochToDate(data.claimed_at) : null;
return this;
}

public toJSON(): AuthConfigJSONSnapshot {
return {
object: 'auth_config',
id: this.id || '',
single_session_mode: this.singleSessionMode,
claimed_at: this.claimedAt ? this.claimedAt.getTime() : null,
};
}
}
10 changes: 7 additions & 3 deletions packages/clerk-js/src/core/resources/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { FraudProtection } from '../fraudProtection';
import type { Clerk } from './internal';
import { ClerkAPIResponseError, ClerkRuntimeError, Client } from './internal';

export type BaseFetchOptions = ClerkResourceReloadParams & { forceUpdateClient?: boolean };
export type BaseFetchOptions = ClerkResourceReloadParams & {
forceUpdateClient?: boolean;
fetchMaxTries?: number;
};

export type BaseMutateParams = {
action?: string;
Expand Down Expand Up @@ -78,9 +81,10 @@ export abstract class BaseResource {
}

let fapiResponse: FapiResponse<J>;
const { fetchMaxTries } = opts;

try {
fapiResponse = await BaseResource.fapiClient.request<J>(requestInit);
fapiResponse = await BaseResource.fapiClient.request<J>(requestInit, { fetchMaxTries });
} catch (e) {
// 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
if (this.shouldRethrowOfflineNetworkErrors()) {
Expand Down Expand Up @@ -144,7 +148,7 @@ export abstract class BaseResource {
const client = responseJSON.client || responseJSON.meta?.client;

if (client && BaseResource.clerk) {
BaseResource.clerk.updateClient(Client.getInstance().fromJSON(client));
BaseResource.clerk.updateClient(Client.getOrCreateInstance().fromJSON(client));
}
}

Expand Down
Loading

0 comments on commit dd3fdc7

Please sign in to comment.