Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .changeset/expo-native-app-minimum-versions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@clerk/expo': minor
---

Support the minimum app versions set in the Clerk Dashboard. When your iOS or Android app's version is older than its minimum, Clerk rejects the app's requests until the user updates, and `<ClerkProvider>` shows a full-screen `<UpdateRequiredView />` over your app with a link to the store and, when your instance has a support email, a way to contact support.

To show your own screen instead, set `updateRequiredViewEnabled={false}` on `<ClerkProvider>` and read the new `useAppVersionSupport()` hook, which returns `isSupported`, `currentVersion`, `minimumVersion`, and `updateUrl`. You can also render `<UpdateRequiredView />` yourself.

Minimum versions need `expo-application` (`npx expo install expo-application`), which Clerk uses to read your app's bundle ID or package name and its version. Apps without `expo-application`, apps in Expo Go, and web apps are never blocked.
5 changes: 5 additions & 0 deletions packages/expo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
"@types/base-64": "^1.0.2",
"esbuild": "^0.28.1",
"expo-apple-authentication": "^8.0.8",
"expo-application": "^7.0.8",
"expo-auth-session": "^7.0.11",
"expo-constants": "^18.0.13",
"expo-crypto": "^15.0.9",
Expand All @@ -154,6 +155,7 @@
"@clerk/expo-passkeys": ">=0.0.6",
"expo": "catalog:peer-expo",
"expo-apple-authentication": ">=7.0.0",
"expo-application": ">=5.0.0",
"expo-auth-session": ">=5",
"expo-constants": ">=12",
"expo-crypto": ">=12",
Expand All @@ -180,6 +182,9 @@
"expo-apple-authentication": {
"optional": true
},
"expo-application": {
"optional": true
},
"expo-auth-session": {
"optional": true
},
Expand Down
167 changes: 167 additions & 0 deletions packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';

const appState = vi.hoisted(() => {
const listeners = new Set<(state: string) => void>();
return {
listeners,
addEventListener: (_type: string, listener: (state: string) => void) => {
listeners.add(listener);
return { remove: () => listeners.delete(listener) };
},
emit: (state: string) => listeners.forEach(listener => listener(state)),
};
});

vi.mock('react-native', () => ({ AppState: { addEventListener: appState.addEventListener } }));

vi.mock('../nativeAppIdentity', () => ({
getNativeAppIdentity: () => ({
platform: 'ios',
appIdentifier: 'com.example.app',
version: '1.0',
displayVersion: '1.0',
}),
getNativeAppPlatform: () => 'ios',
isExpoApplicationInstalled: () => true,
setNativeAppIdentityHeaders: (headers: Headers) => headers.set('x-bundle-id', 'com.example.app'),
}));

const BLOCKING_ENVIRONMENT = {
native_app_settings: {
minimum_supported_version: {
ios: [{ bundle_id: 'com.example.app', minimum_version: '2.0', update_url: 'https://apps.apple.com/app/id1' }],
android: [],
},
},
};

const response = ({
status = 200,
headers = {},
payload = null,
}: {
status?: number;
headers?: Record<string, string>;
payload?: unknown;
}) => ({ ok: status >= 200 && status < 300, status, headers: new Headers(headers), payload });

const unsupportedAppVersion = response({
status: 403,
payload: { errors: [{ code: 'unsupported_app_version', meta: { minimum_version: '2.0' } }] },
});

const loadModules = async () => {
const hooks = await import('../appVersionRequestHooks');
const store = await import('../appVersionSupport');
return { createAppVersionRequestHooks: hooks.createAppVersionRequestHooks, status: store.getAppVersionSupportStatus };
};

describe('createAppVersionRequestHooks', () => {
beforeEach(() => {
vi.resetModules();
appState.listeners.clear();
});

test('identifies the app on every request', async () => {
const { createAppVersionRequestHooks } = await loadModules();
const requestInit = { path: '/client', headers: new Headers() };

createAppVersionRequestHooks().beforeRequest(requestInit);

expect(requestInit.headers.get('x-bundle-id')).toBe('com.example.app');
});

test('blocks the app on an unsupported app version response', async () => {
const { createAppVersionRequestHooks, status } = await loadModules();
const hooks = createAppVersionRequestHooks();
const requestInit = { path: '/client', headers: new Headers() };
hooks.beforeRequest(requestInit);

hooks.afterResponse(requestInit, unsupportedAppVersion);

expect(status()).toMatchObject({ isSupported: false, minimumVersion: '2.0' });
});

test('leaves the app supported on another 403', async () => {
const { createAppVersionRequestHooks, status } = await loadModules();
const hooks = createAppVersionRequestHooks();
const requestInit = { path: '/client', headers: new Headers() };

hooks.afterResponse(requestInit, response({ status: 403, payload: { errors: [{ code: 'user_banned' }] } }));

expect(status().isSupported).toBe(true);
});

test('the supported header lifts a block', async () => {
const { createAppVersionRequestHooks, status } = await loadModules();
const hooks = createAppVersionRequestHooks();
const requestInit = { path: '/client', headers: new Headers() };
hooks.afterResponse(requestInit, unsupportedAppVersion);

hooks.afterResponse(requestInit, response({ headers: { 'X-Clerk-App-Version-Status': 'supported' } }));

expect(status().isSupported).toBe(true);
});

test('a loaded environment blocks an outdated app', async () => {
const { createAppVersionRequestHooks, status } = await loadModules();
const hooks = createAppVersionRequestHooks();
const requestInit = { path: '/environment', headers: new Headers() };
hooks.beforeRequest(requestInit);

hooks.afterResponse(requestInit, response({ payload: BLOCKING_ENVIRONMENT }));

expect(status()).toMatchObject({ isSupported: false, minimumVersion: '2.0' });
});

test('an environment that loads after a rejection keeps the block', async () => {
const { createAppVersionRequestHooks, status } = await loadModules();
const hooks = createAppVersionRequestHooks();
const environmentRequest = { path: '/environment', headers: new Headers() };
const clientRequest = { path: '/client', headers: new Headers() };
hooks.beforeRequest(clientRequest);
hooks.afterResponse(clientRequest, unsupportedAppVersion);

hooks.beforeRequest(environmentRequest);
hooks.afterResponse(environmentRequest, response({ payload: { native_app_settings: {} } }));

expect(status().isSupported).toBe(false);
});

test('ignores responses to a Clerk instance that was replaced', async () => {
const { createAppVersionRequestHooks, status } = await loadModules();
const staleHooks = createAppVersionRequestHooks();
createAppVersionRequestHooks();

staleHooks.afterResponse({ path: '/client', headers: new Headers() }, unsupportedAppVersion);

expect(status().isSupported).toBe(true);
});

test('reloads when the app returns to the foreground while unsupported', async () => {
const { createAppVersionRequestHooks } = await loadModules();
const hooks = createAppVersionRequestHooks();
const refresh = vi.fn().mockResolvedValue(undefined);
hooks.refreshOnForegroundWhileUnsupported(refresh);

appState.emit('active');
expect(refresh).not.toHaveBeenCalled();

hooks.afterResponse({ path: '/client', headers: new Headers() }, unsupportedAppVersion);
appState.emit('background');
appState.emit('active');
expect(refresh).toHaveBeenCalledTimes(1);
});

test('stops reloading for a Clerk instance that was replaced', async () => {
const { createAppVersionRequestHooks } = await loadModules();
const staleRefresh = vi.fn().mockResolvedValue(undefined);
createAppVersionRequestHooks().refreshOnForegroundWhileUnsupported(staleRefresh);
const hooks = createAppVersionRequestHooks();
hooks.afterResponse({ path: '/client', headers: new Headers() }, unsupportedAppVersion);

appState.emit('active');

expect(staleRefresh).not.toHaveBeenCalled();
});
});
Loading
Loading