diff --git a/.changeset/expo-native-app-minimum-versions.md b/.changeset/expo-native-app-minimum-versions.md new file mode 100644 index 00000000000..a2669bc9a75 --- /dev/null +++ b/.changeset/expo-native-app-minimum-versions.md @@ -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 `` shows a full-screen `` 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 `` and read the new `useAppVersionSupport()` hook, which returns `isSupported`, `currentVersion`, `minimumVersion`, and `updateUrl`. You can also render `` 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. diff --git a/packages/expo/package.json b/packages/expo/package.json index f1f01d65fad..ba79f7d113c 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -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", @@ -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", @@ -180,6 +182,9 @@ "expo-apple-authentication": { "optional": true }, + "expo-application": { + "optional": true + }, "expo-auth-session": { "optional": true }, diff --git a/packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts b/packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts new file mode 100644 index 00000000000..963b21435e7 --- /dev/null +++ b/packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts @@ -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; + 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(); + }); +}); diff --git a/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts b/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts new file mode 100644 index 00000000000..6ca5867f515 --- /dev/null +++ b/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts @@ -0,0 +1,282 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { NativeAppIdentity } from '../nativeAppIdentity'; + +const mocks = vi.hoisted(() => ({ + identity: null as NativeAppIdentity | null, + expoApplicationInstalled: true, +})); + +vi.mock('../nativeAppIdentity', () => ({ + getNativeAppIdentity: () => mocks.identity, + getNativeAppPlatform: () => 'ios', + isExpoApplicationInstalled: () => mocks.expoApplicationInstalled, +})); + +const PLAY_URL = 'https://play.google.com/store/apps/details?id=com.example.app'; +const APP_STORE_URL = 'https://apps.apple.com/app/id123'; + +const environment = ({ + ios = [] as unknown[], + android = [] as unknown[], +}: { + ios?: unknown[]; + android?: unknown[]; +} = {}) => ({ native_app_settings: { minimum_supported_version: { ios, android } } }); + +const iosPolicy = (minimumVersion: unknown, updateUrl: unknown = APP_STORE_URL) => ({ + bundle_id: 'com.example.app', + minimum_version: minimumVersion, + update_url: updateUrl, +}); + +const loadStore = async () => import('../appVersionSupport'); + +describe('appVersionSupport', () => { + beforeEach(() => { + vi.resetModules(); + mocks.identity = { platform: 'ios', appIdentifier: 'com.example.app', version: '1.9.9', displayVersion: '1.9.9' }; + mocks.expoApplicationInstalled = true; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + const fetchEnvironment = (store: Awaited>, json: unknown) => { + store.applyFetchedEnvironment(json, store.getAppVersionSupportGeneration()); + }; + + test('reports a supported app before anything is known', async () => { + const store = await loadStore(); + + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: true, + currentVersion: '1.9.9', + minimumVersion: null, + updateUrl: null, + }); + }); + + test('blocks an iOS app older than its minimum', async () => { + const store = await loadStore(); + + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: false, + currentVersion: '1.9.9', + minimumVersion: '2.0', + updateUrl: APP_STORE_URL, + }); + }); + + test.each([ + ['at its minimum', '1.10.0'], + ['at its minimum written with fewer components', '1.10'], + ['above its minimum', '1.9.9'], + ])('lets an iOS app %s through', async (_label, minimumVersion) => { + mocks.identity = { platform: 'ios', appIdentifier: 'com.example.app', version: '1.10.0', displayVersion: '1.10.0' }; + const store = await loadStore(); + + fetchEnvironment(store, environment({ ios: [iosPolicy(minimumVersion)] })); + + expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + }); + + test('compares Android version codes', async () => { + mocks.identity = { platform: 'android', appIdentifier: 'com.example.app', version: '41', displayVersion: '41' }; + const store = await loadStore(); + + fetchEnvironment( + store, + environment({ android: [{ package_name: 'com.example.app', minimum_version: '42', update_url: PLAY_URL }] }), + ); + + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: false, + currentVersion: '41', + minimumVersion: '42', + updateUrl: PLAY_URL, + }); + }); + + test.each([['4.2.1.0'], ['abc'], [''], [42]])( + 'lets the app through when the minimum %j is unreadable', + async minimum => { + const store = await loadStore(); + + fetchEnvironment(store, environment({ ios: [iosPolicy(minimum)] })); + + expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + }, + ); + + test('ignores minimums for other apps and malformed settings', async () => { + const store = await loadStore(); + + fetchEnvironment( + store, + environment({ ios: ['not a policy', { bundle_id: 'com.example.other', minimum_version: '9.0' }] }), + ); + expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + + fetchEnvironment(store, { native_app_settings: 'not settings' }); + expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + }); + + test.each([['market://details?id=com.example.app'], ['javascript:alert(1)'], ['not a url']])( + 'drops the update URL %s', + async updateUrl => { + const store = await loadStore(); + + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0', updateUrl)] })); + + expect(store.getAppVersionSupportStatus().updateUrl).toBeNull(); + }, + ); + + test('a rejection blocks the app until a later response marks it supported', async () => { + const store = await loadStore(); + const generation = store.getAppVersionSupportGeneration(); + + store.applyUnsupportedAppVersion({ minimum_version: '2.0', update_url: APP_STORE_URL }, generation); + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: false, + currentVersion: '1.9.9', + minimumVersion: '2.0', + updateUrl: APP_STORE_URL, + }); + + store.applySupportedAppVersion(generation); + expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + }); + + test('a rejection falls back to the environment for what its meta leaves out', async () => { + const store = await loadStore(); + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + + store.applyUnsupportedAppVersion({ update_url: '' }, store.getAppVersionSupportGeneration()); + + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: false, + currentVersion: '1.9.9', + minimumVersion: '2.0', + updateUrl: APP_STORE_URL, + }); + }); + + test('an environment does not lift a rejection', async () => { + const store = await loadStore(); + store.applyUnsupportedAppVersion({ minimum_version: '2.0' }, store.getAppVersionSupportGeneration()); + + fetchEnvironment(store, environment()); + + expect(store.getAppVersionSupportStatus()).toMatchObject({ isSupported: false, minimumVersion: '2.0' }); + }); + + test('an environment does not block an app a response marked supported', async () => { + const store = await loadStore(); + store.applySupportedAppVersion(store.getAppVersionSupportGeneration()); + + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: true, + currentVersion: '1.9.9', + minimumVersion: null, + updateUrl: null, + }); + }); + + test('a supported answer shows the minimum a later environment sets', async () => { + mocks.identity = { platform: 'ios', appIdentifier: 'com.example.app', version: '2.1', displayVersion: '2.1' }; + const store = await loadStore(); + store.applySupportedAppVersion(store.getAppVersionSupportGeneration()); + + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + + expect(store.getAppVersionSupportStatus()).toMatchObject({ isSupported: true, minimumVersion: '2.0' }); + }); + + test('a supported answer keeps the minimum and store page the environment set', async () => { + mocks.identity = { platform: 'ios', appIdentifier: 'com.example.app', version: '2.1', displayVersion: '2.1' }; + const store = await loadStore(); + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + + store.applySupportedAppVersion(store.getAppVersionSupportGeneration()); + + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: true, + currentVersion: '2.1', + minimumVersion: '2.0', + updateUrl: APP_STORE_URL, + }); + }); + + test('a supported answer drops a minimum the server no longer enforces', async () => { + const store = await loadStore(); + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + + store.applySupportedAppVersion(store.getAppVersionSupportGeneration()); + + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: true, + currentVersion: '1.9.9', + minimumVersion: null, + updateUrl: null, + }); + }); + + test('ignores answers for a Clerk instance that was replaced', async () => { + const store = await loadStore(); + const staleGeneration = store.getAppVersionSupportGeneration(); + store.resetAppVersionSupport(); + + store.applyUnsupportedAppVersion({ minimum_version: '2.0' }, staleGeneration); + + expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + }); + + test('notifies subscribers only when the status changes', async () => { + const store = await loadStore(); + const listener = vi.fn(); + store.subscribeToAppVersionSupport(listener); + const generation = store.getAppVersionSupportGeneration(); + + store.applyUnsupportedAppVersion({ minimum_version: '2.0' }, generation); + store.applyUnsupportedAppVersion({ minimum_version: '2.0' }, generation); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + describe('without expo-application', () => { + beforeEach(() => { + mocks.identity = null; + mocks.expoApplicationInstalled = false; + vi.stubGlobal('__DEV__', true); + }); + + test('warns once in development when the instance sets minimums', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const store = await loadStore(); + + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('npx expo install expo-application'); + expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + }); + + test('stays quiet when the instance sets no minimums', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const store = await loadStore(); + + fetchEnvironment(store, environment()); + + expect(warn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/expo/src/app-version/__tests__/nativeAppIdentity.test.ts b/packages/expo/src/app-version/__tests__/nativeAppIdentity.test.ts new file mode 100644 index 00000000000..041259d90d1 --- /dev/null +++ b/packages/expo/src/app-version/__tests__/nativeAppIdentity.test.ts @@ -0,0 +1,116 @@ +import Module from 'node:module'; + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const moduleWithLoad = Module as unknown as { + _load: (request: string, parent?: unknown, isMain?: boolean) => unknown; +}; +const originalModuleLoad = moduleWithLoad._load; + +const mocks = vi.hoisted(() => ({ + platformOS: 'ios' as string, + application: null as Record | null, + executionEnvironment: 'standalone' as string, +})); + +vi.mock('react-native', () => ({ + AppState: { currentState: 'active', addEventListener: vi.fn() }, + Platform: { + get OS() { + return mocks.platformOS; + }, + }, +})); + +const loadIdentity = async () => import('../nativeAppIdentity'); + +describe('nativeAppIdentity', () => { + beforeEach(() => { + vi.resetModules(); + mocks.platformOS = 'ios'; + mocks.executionEnvironment = 'standalone'; + mocks.application = { + applicationId: 'com.example.app', + nativeApplicationVersion: '4.8.4', + nativeBuildVersion: '41', + }; + vi.spyOn(moduleWithLoad, '_load').mockImplementation((request, parent, isMain) => { + if (request === 'expo-application') { + if (!mocks.application) { + throw new Error("Cannot find module 'expo-application'"); + } + return mocks.application; + } + if (request === 'expo-constants') { + return { default: { executionEnvironment: mocks.executionEnvironment, appOwnership: null } }; + } + return originalModuleLoad.call(Module, request, parent, isMain); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('identifies an iOS app by its bundle ID and version', async () => { + const { getNativeAppIdentity } = await loadIdentity(); + + expect(getNativeAppIdentity()).toEqual({ + platform: 'ios', + appIdentifier: 'com.example.app', + version: '4.8.4', + displayVersion: '4.8.4', + }); + }); + + test('identifies an Android app by its package name and version code', async () => { + mocks.platformOS = 'android'; + const { getNativeAppIdentity } = await loadIdentity(); + + expect(getNativeAppIdentity()).toEqual({ + platform: 'android', + appIdentifier: 'com.example.app', + version: '41', + displayVersion: '4.8.4', + }); + }); + + test('sends the identity headers for iOS', async () => { + const { setNativeAppIdentityHeaders } = await loadIdentity(); + const headers = new Headers(); + + setNativeAppIdentityHeaders(headers); + + expect(headers.get('x-bundle-id')).toBe('com.example.app'); + expect(headers.get('x-package-name')).toBeNull(); + expect(headers.get('x-app-version')).toBe('4.8.4'); + }); + + test('sends the identity headers for Android', async () => { + mocks.platformOS = 'android'; + const { setNativeAppIdentityHeaders } = await loadIdentity(); + const headers = new Headers(); + + setNativeAppIdentityHeaders(headers); + + expect(headers.get('x-package-name')).toBe('com.example.app'); + expect(headers.get('x-bundle-id')).toBeNull(); + expect(headers.get('x-app-version')).toBe('41'); + }); + + test.each([ + ['in Expo Go', () => (mocks.executionEnvironment = 'storeClient')], + ['on the web', () => (mocks.platformOS = 'web')], + ['without expo-application', () => (mocks.application = null)], + ['without an app version', () => (mocks.application = { ...mocks.application, nativeApplicationVersion: null })], + ])('sends no identity %s', async (_label, arrange) => { + arrange(); + const { getNativeAppIdentity, setNativeAppIdentityHeaders } = await loadIdentity(); + const headers = new Headers(); + + setNativeAppIdentityHeaders(headers); + + expect(getNativeAppIdentity()).toBeNull(); + expect([...headers.keys()]).toEqual([]); + }); +}); diff --git a/packages/expo/src/app-version/__tests__/useClerkRestartWhenSupported.test.ts b/packages/expo/src/app-version/__tests__/useClerkRestartWhenSupported.test.ts new file mode 100644 index 00000000000..56cf2d6e17a --- /dev/null +++ b/packages/expo/src/app-version/__tests__/useClerkRestartWhenSupported.test.ts @@ -0,0 +1,68 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('../nativeAppIdentity', () => ({ + getNativeAppIdentity: () => ({ + platform: 'ios', + appIdentifier: 'com.example.app', + version: '1.0', + displayVersion: '1.0', + }), + getNativeAppPlatform: () => 'ios', + isExpoApplicationInstalled: () => true, +})); + +const loadModules = async () => { + const store = await import('../appVersionSupport'); + const { useClerkRestartWhenSupported } = await import('../useClerkRestartWhenSupported'); + return { store, useClerkRestartWhenSupported }; +}; + +describe('useClerkRestartWhenSupported', () => { + beforeEach(() => { + vi.resetModules(); + }); + + const block = (store: Awaited>['store']) => + act(() => { + store.applyUnsupportedAppVersion({ minimum_version: '2.0' }, store.getAppVersionSupportGeneration()); + }); + + const unblock = (store: Awaited>['store']) => + act(() => { + store.applySupportedAppVersion(store.getAppVersionSupportGeneration()); + }); + + test('restarts Clerk when the app becomes supported after Clerk failed to load', async () => { + const { store, useClerkRestartWhenSupported } = await loadModules(); + const discard = vi.fn(); + const { result } = renderHook(() => useClerkRestartWhenSupported({ status: 'error' }, discard)); + + block(store); + expect(result.current).toBe(0); + unblock(store); + + expect(discard).toHaveBeenCalledTimes(1); + expect(result.current).toBe(1); + }); + + test('leaves a loaded Clerk alone when the block lifts', async () => { + const { store, useClerkRestartWhenSupported } = await loadModules(); + const discard = vi.fn(); + const { result } = renderHook(() => useClerkRestartWhenSupported({ status: 'ready' }, discard)); + + block(store); + unblock(store); + + expect(discard).not.toHaveBeenCalled(); + expect(result.current).toBe(0); + }); + + test('leaves a failed Clerk alone when the app was never blocked', async () => { + const discard = vi.fn(); + const { useClerkRestartWhenSupported } = await loadModules(); + renderHook(() => useClerkRestartWhenSupported({ status: 'error' }, discard)); + + expect(discard).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/expo/src/app-version/appVersionRequestHooks.ts b/packages/expo/src/app-version/appVersionRequestHooks.ts new file mode 100644 index 00000000000..543c01119d8 --- /dev/null +++ b/packages/expo/src/app-version/appVersionRequestHooks.ts @@ -0,0 +1,73 @@ +import { AppState, type NativeEventSubscription } from 'react-native'; + +import { + applyFetchedEnvironment, + applySupportedAppVersion, + applyUnsupportedAppVersion, + getAppVersionSupportGeneration, + getAppVersionSupportStatus, + isRecord, + resetAppVersionSupport, +} from './appVersionSupport'; +import { setNativeAppIdentityHeaders } from './nativeAppIdentity'; + +const UNSUPPORTED_APP_VERSION_CODE = 'unsupported_app_version'; +const APP_VERSION_STATUS_HEADER = 'x-clerk-app-version-status'; +const ENVIRONMENT_PATH = '/environment'; + +type AppVersionRequest = { + path?: string; + headers?: HeadersInit; +}; + +type AppVersionResponse = { + ok: boolean; + status: number; + headers: Headers; + payload: unknown; +}; + +let foregroundSubscription: NativeEventSubscription | null = null; + +export function createAppVersionRequestHooks() { + resetAppVersionSupport(); + foregroundSubscription?.remove(); + foregroundSubscription = null; + const generation = getAppVersionSupportGeneration(); + + return { + refreshOnForegroundWhileUnsupported(refresh: () => Promise) { + foregroundSubscription = AppState.addEventListener('change', state => { + if (state === 'active' && !getAppVersionSupportStatus().isSupported) { + void refresh().catch(() => {}); + } + }); + }, + beforeRequest(requestInit: AppVersionRequest) { + setNativeAppIdentityHeaders(requestInit.headers as Headers); + }, + afterResponse(requestInit: AppVersionRequest, response: AppVersionResponse) { + const unsupportedAppVersionError = + response.status === 403 ? findUnsupportedAppVersionError(response.payload) : null; + if (unsupportedAppVersionError) { + applyUnsupportedAppVersion(unsupportedAppVersionError.meta, generation); + return; + } + if (response.headers.get(APP_VERSION_STATUS_HEADER)?.toLowerCase() === 'supported') { + applySupportedAppVersion(generation); + } + if (response.ok && requestInit.path === ENVIRONMENT_PATH) { + applyFetchedEnvironment(environmentJSON(response.payload), generation); + } + }, + }; +} + +function findUnsupportedAppVersionError(payload: unknown): { meta?: unknown } | null { + const errors = isRecord(payload) && Array.isArray(payload.errors) ? payload.errors : []; + return errors.find(error => isRecord(error) && error.code === UNSUPPORTED_APP_VERSION_CODE) ?? null; +} + +function environmentJSON(payload: unknown): unknown { + return isRecord(payload) && isRecord(payload.response) ? payload.response : payload; +} diff --git a/packages/expo/src/app-version/appVersionSupport.ts b/packages/expo/src/app-version/appVersionSupport.ts new file mode 100644 index 00000000000..fbf12d5a7dc --- /dev/null +++ b/packages/expo/src/app-version/appVersionSupport.ts @@ -0,0 +1,256 @@ +import type { NativeAppIdentity, NativeAppPlatform } from './nativeAppIdentity'; +import { getNativeAppIdentity, getNativeAppPlatform, isExpoApplicationInstalled } from './nativeAppIdentity'; + +/** + * Whether this build of your app meets the minimum version set for it in the Clerk Dashboard. + */ +export type AppVersionSupportStatus = { + /** + * Whether this app version is supported. When it is `false`, Clerk rejects the app's requests until the user updates. + */ + isSupported: boolean; + /** + * This build's version: its `CFBundleShortVersionString` on iOS and its `versionCode` on Android. `null` when it can't be read, such as in Expo Go or without `expo-application`. + */ + currentVersion: string | null; + /** + * The oldest supported version, when a minimum is set for this app. + */ + minimumVersion: string | null; + /** + * The store page to send users to so they can update. + */ + updateUrl: string | null; +}; + +export type UpdateRequiredDisplay = { + applicationName: string | null; + logoImageUrl: string | null; + supportEmail: string | null; +}; + +const EMPTY_DISPLAY: UpdateRequiredDisplay = { applicationName: null, logoImageUrl: null, supportEmail: null }; + +type MinimumVersionJSON = { + bundle_id?: unknown; + package_name?: unknown; + minimum_version?: unknown; + update_url?: unknown; +}; + +const IOS_VERSION = /^\d+(\.\d+){0,2}$/; +const ANDROID_VERSION_CODE = /^[1-9]\d*$/; + +const listeners = new Set<() => void>(); +let environmentStatus: AppVersionSupportStatus | null = null; +let serverAnswer: { isSupported: true } | { isSupported: false; status: AppVersionSupportStatus } | null = null; +let generation = 0; +let status = supportedStatus(); +let display = EMPTY_DISPLAY; +let missingExpoApplicationWarned = false; + +export function subscribeToAppVersionSupport(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getAppVersionSupportStatus(): AppVersionSupportStatus { + return status; +} + +export function getUpdateRequiredDisplay(): UpdateRequiredDisplay { + return display; +} + +export function getAppVersionSupportGeneration(): number { + return generation; +} + +export function resetAppVersionSupport(): void { + generation += 1; + environmentStatus = null; + serverAnswer = null; + display = EMPTY_DISPLAY; + publish({ displayChanged: true }); +} + +export function applyFetchedEnvironment(environment: unknown, requestGeneration: number): void { + if (requestGeneration !== generation) { + return; + } + environmentStatus = resolveEnvironmentStatus(environment); + const nextDisplay = resolveDisplay(environment); + const displayChanged = !sameDisplay(nextDisplay, display); + if (displayChanged) { + display = nextDisplay; + } + publish({ displayChanged }); +} + +export function applyUnsupportedAppVersion(meta: unknown, requestGeneration: number): void { + if (requestGeneration !== generation) { + return; + } + const fields = isRecord(meta) ? meta : {}; + serverAnswer = { + isSupported: false, + status: { + isSupported: false, + currentVersion: stringOrNull(fields.current_version) ?? getNativeAppIdentity()?.version ?? null, + minimumVersion: stringOrNull(fields.minimum_version) ?? environmentStatus?.minimumVersion ?? null, + updateUrl: httpUrlOrNull(fields.update_url) ?? environmentStatus?.updateUrl ?? null, + }, + }; + publish(); +} + +export function applySupportedAppVersion(requestGeneration: number): void { + if (requestGeneration !== generation) { + return; + } + serverAnswer = { isSupported: true }; + publish(); +} + +function resolveEnvironmentStatus(environment: unknown): AppVersionSupportStatus { + const identity = getNativeAppIdentity(); + const minimums = minimumVersionsForPlatform(environment, identity?.platform ?? getNativeAppPlatform()); + if (!identity) { + warnIfExpoApplicationIsMissing(minimums.length > 0); + return supportedStatus(); + } + const policy = minimums.find(entry => appIdentifier(entry, identity.platform) === identity.appIdentifier); + if (!policy) { + return supportedStatus(); + } + const minimumVersion = stringOrNull(policy.minimum_version); + const meetsMinimum = minimumVersion ? isAtLeast(identity, minimumVersion) : null; + return { + isSupported: meetsMinimum !== false, + currentVersion: identity.version, + minimumVersion: meetsMinimum === null ? null : minimumVersion, + updateUrl: httpUrlOrNull(policy.update_url), + }; +} + +function minimumVersionsForPlatform(environment: unknown, platform: NativeAppPlatform | null): MinimumVersionJSON[] { + if (!platform || !isRecord(environment)) { + return []; + } + const settings = environment.native_app_settings; + const minimums = isRecord(settings) ? settings.minimum_supported_version : undefined; + const entries = isRecord(minimums) ? minimums[platform] : undefined; + return Array.isArray(entries) ? entries.filter(isRecord) : []; +} + +function appIdentifier(entry: MinimumVersionJSON, platform: NativeAppPlatform): string | null { + return stringOrNull(platform === 'ios' ? entry.bundle_id : entry.package_name); +} + +function isAtLeast(identity: NativeAppIdentity, minimumVersion: string): boolean | null { + const current = parseVersion(identity.platform, identity.version); + const required = parseVersion(identity.platform, minimumVersion); + if (!current || !required) { + return null; + } + for (let index = 0; index < Math.max(current.length, required.length); index++) { + const difference = (current[index] ?? 0) - (required[index] ?? 0); + if (difference !== 0) { + return difference > 0; + } + } + return true; +} + +function parseVersion(platform: NativeAppPlatform, version: string): number[] | null { + const pattern = platform === 'ios' ? IOS_VERSION : ANDROID_VERSION_CODE; + if (!pattern.test(version)) { + return null; + } + const components = version.split('.').map(Number); + return components.every(Number.isSafeInteger) ? components : null; +} + +function httpUrlOrNull(value: unknown): string | null { + const url = stringOrNull(value); + if (!url) { + return null; + } + try { + const parsed = new URL(url); + return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && parsed.hostname ? url : null; + } catch { + return null; + } +} + +function warnIfExpoApplicationIsMissing(appHasMinimums: boolean): void { + if (!__DEV__ || missingExpoApplicationWarned || !appHasMinimums || isExpoApplicationInstalled()) { + return; + } + missingExpoApplicationWarned = true; + console.warn( + 'Clerk: This instance sets minimum app versions, but Clerk cannot read your app version without expo-application, so it will not block outdated versions.\nInstall it with: npx expo install expo-application', + ); +} + +function supportedStatus(): AppVersionSupportStatus { + return { + isSupported: true, + currentVersion: getNativeAppIdentity()?.version ?? null, + minimumVersion: null, + updateUrl: null, + }; +} + +function publish({ displayChanged = false }: { displayChanged?: boolean } = {}): void { + const next = !serverAnswer + ? (environmentStatus ?? supportedStatus()) + : !serverAnswer.isSupported + ? serverAnswer.status + : environmentStatus?.isSupported + ? environmentStatus + : supportedStatus(); + const statusChanged = !sameStatus(next, status); + if (statusChanged) { + status = next; + } + if (statusChanged || displayChanged) { + listeners.forEach(listener => listener()); + } +} + +function resolveDisplay(environment: unknown): UpdateRequiredDisplay { + const displayConfig = isRecord(environment) ? environment.display_config : undefined; + if (!isRecord(displayConfig)) { + return display; + } + return { + applicationName: stringOrNull(displayConfig.application_name), + logoImageUrl: httpUrlOrNull(displayConfig.logo_image_url), + supportEmail: stringOrNull(displayConfig.support_email), + }; +} + +function sameDisplay(a: UpdateRequiredDisplay, b: UpdateRequiredDisplay): boolean { + return ( + a.applicationName === b.applicationName && a.logoImageUrl === b.logoImageUrl && a.supportEmail === b.supportEmail + ); +} + +function sameStatus(a: AppVersionSupportStatus, b: AppVersionSupportStatus): boolean { + return ( + a.isSupported === b.isSupported && + a.currentVersion === b.currentVersion && + a.minimumVersion === b.minimumVersion && + a.updateUrl === b.updateUrl + ); +} + +function stringOrNull(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/expo/src/app-version/nativeAppIdentity.ts b/packages/expo/src/app-version/nativeAppIdentity.ts new file mode 100644 index 00000000000..578f3cd55c6 --- /dev/null +++ b/packages/expo/src/app-version/nativeAppIdentity.ts @@ -0,0 +1,72 @@ +import { Platform } from 'react-native'; + +import { isExpoGo } from '../utils/runtime'; + +export type NativeAppPlatform = 'ios' | 'android'; + +export type NativeAppIdentity = { + platform: NativeAppPlatform; + appIdentifier: string; + version: string; + displayVersion: string; +}; + +type ExpoApplication = { + applicationId: string | null; + applicationName: string | null; + nativeApplicationVersion: string | null; + nativeBuildVersion: string | null; +}; + +let cachedIdentity: NativeAppIdentity | null | undefined; + +export function getNativeAppIdentity(): NativeAppIdentity | null { + if (cachedIdentity === undefined) { + cachedIdentity = readNativeAppIdentity(); + } + return cachedIdentity; +} + +export function getNativeAppPlatform(): NativeAppPlatform | null { + return Platform.OS === 'ios' || Platform.OS === 'android' ? Platform.OS : null; +} + +function readNativeAppIdentity(): NativeAppIdentity | null { + const platform = getNativeAppPlatform(); + if (!platform || isExpoGo()) { + return null; + } + const application = loadExpoApplication(); + const appIdentifier = application?.applicationId; + const version = platform === 'ios' ? application?.nativeApplicationVersion : application?.nativeBuildVersion; + if (!appIdentifier || !version) { + return null; + } + return { platform, appIdentifier, version, displayVersion: application?.nativeApplicationVersion || version }; +} + +export function getNativeAppName(): string | null { + return loadExpoApplication()?.applicationName || null; +} + +export function isExpoApplicationInstalled(): boolean { + return loadExpoApplication() !== null; +} + +function loadExpoApplication(): ExpoApplication | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('expo-application') as ExpoApplication; + } catch { + return null; + } +} + +export function setNativeAppIdentityHeaders(headers: Headers): void { + const identity = getNativeAppIdentity(); + if (!identity) { + return; + } + headers.set(identity.platform === 'ios' ? 'x-bundle-id' : 'x-package-name', identity.appIdentifier); + headers.set('x-app-version', identity.version); +} diff --git a/packages/expo/src/app-version/useClerkRestartWhenSupported.ts b/packages/expo/src/app-version/useClerkRestartWhenSupported.ts new file mode 100644 index 00000000000..cc28a3fd1f2 --- /dev/null +++ b/packages/expo/src/app-version/useClerkRestartWhenSupported.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef, useState } from 'react'; + +import { useAppVersionSupport } from '../hooks/useAppVersionSupport'; + +type LoadableClerk = { status?: string } | null; + +export function useClerkRestartWhenSupported(clerk: LoadableClerk, discardClerk: () => void): number { + const { isSupported } = useAppVersionSupport(); + const [restarts, setRestarts] = useState(0); + const wasUnsupported = useRef(false); + + useEffect(() => { + if (!isSupported) { + wasUnsupported.current = true; + return; + } + const failedToLoadWhileUnsupported = wasUnsupported.current && clerk?.status === 'error'; + wasUnsupported.current = false; + if (failedToLoadWhileUnsupported) { + discardClerk(); + setRestarts(count => count + 1); + } + }, [isSupported, clerk, discardClerk]); + + return restarts; +} diff --git a/packages/expo/src/hooks/__tests__/useAppVersionSupport.test.ts b/packages/expo/src/hooks/__tests__/useAppVersionSupport.test.ts new file mode 100644 index 00000000000..0cdf9e11dcd --- /dev/null +++ b/packages/expo/src/hooks/__tests__/useAppVersionSupport.test.ts @@ -0,0 +1,32 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +vi.mock('../../app-version/nativeAppIdentity', () => ({ + getNativeAppIdentity: () => ({ + platform: 'ios', + appIdentifier: 'com.example.app', + version: '1.0', + displayVersion: '1.0', + }), + getNativeAppPlatform: () => 'ios', + isExpoApplicationInstalled: () => true, +})); + +describe('useAppVersionSupport', () => { + test('re-renders when the app becomes unsupported and supported again', async () => { + const store = await import('../../app-version/appVersionSupport'); + const { useAppVersionSupport } = await import('../useAppVersionSupport'); + const { result } = renderHook(() => useAppVersionSupport()); + expect(result.current.isSupported).toBe(true); + + act(() => { + store.applyUnsupportedAppVersion({ minimum_version: '2.0' }, store.getAppVersionSupportGeneration()); + }); + expect(result.current).toMatchObject({ isSupported: false, currentVersion: '1.0', minimumVersion: '2.0' }); + + act(() => { + store.applySupportedAppVersion(store.getAppVersionSupportGeneration()); + }); + expect(result.current.isSupported).toBe(true); + }); +}); diff --git a/packages/expo/src/hooks/index.ts b/packages/expo/src/hooks/index.ts index 92644a4eee3..d6e5cd7dbb3 100644 --- a/packages/expo/src/hooks/index.ts +++ b/packages/expo/src/hooks/index.ts @@ -17,3 +17,4 @@ export { export * from './useSSO'; export * from './useOAuth'; export * from './useAuth'; +export * from './useAppVersionSupport'; diff --git a/packages/expo/src/hooks/useAppVersionSupport.ts b/packages/expo/src/hooks/useAppVersionSupport.ts new file mode 100644 index 00000000000..6e9f16d136e --- /dev/null +++ b/packages/expo/src/hooks/useAppVersionSupport.ts @@ -0,0 +1,25 @@ +import { useSyncExternalStore } from 'react'; + +import type { AppVersionSupportStatus } from '../app-version/appVersionSupport'; +import { getAppVersionSupportStatus, subscribeToAppVersionSupport } from '../app-version/appVersionSupport'; + +export type { AppVersionSupportStatus } from '../app-version/appVersionSupport'; + +/** + * Returns whether this build of your app meets the minimum version set for it in the Clerk Dashboard, and updates when that changes. + * + * When the version is unsupported, Clerk rejects the app's requests until the user updates. Read `updateUrl` to send them to the store. + * + * Clerk reads the app's version with `expo-application`. Without it, or in Expo Go, the app is always reported as supported. + * + * @example + * ```tsx + * const { isSupported, updateUrl } = useAppVersionSupport(); + * if (!isSupported) { + * return ; + * } + * ``` + */ +export function useAppVersionSupport(): AppVersionSupportStatus { + return useSyncExternalStore(subscribeToAppVersionSupport, getAppVersionSupportStatus, getAppVersionSupportStatus); +} diff --git a/packages/expo/src/hooks/useHostedAuth.ts b/packages/expo/src/hooks/useHostedAuth.ts index 632173cff42..ab7019b2678 100644 --- a/packages/expo/src/hooks/useHostedAuth.ts +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -8,6 +8,7 @@ import { getClerkInstance } from '../provider/singleton'; import { errorThrower } from '../utils/errors'; import type { HostedAuthClerkInstance, HostedAuthMode } from '../utils/hostedAuth'; import { applyHostedAuthClientJSON, createHostedAuth, redeemHostedAuth } from '../utils/hostedAuth'; +import { isExpoGo } from '../utils/runtime'; export type { HostedAuthMode }; @@ -243,20 +244,17 @@ function getExpoAppIdentifier(): string | undefined { // eslint-disable-next-line @typescript-eslint/no-require-imports const constantsModule = require('expo-constants') as { default?: { - appOwnership?: string | null; - executionEnvironment?: string; expoConfig?: { ios?: { bundleIdentifier?: string }; android?: { package?: string }; }; }; }; - const constants = constantsModule.default; - if (constants?.executionEnvironment === 'storeClient' || constants?.appOwnership === 'expo') { + if (isExpoGo()) { return undefined; } - const expoConfig = constants?.expoConfig; + const expoConfig = constantsModule.default?.expoConfig; return Platform.OS === 'ios' ? expoConfig?.ios?.bundleIdentifier : expoConfig?.android?.package; } catch { return undefined; diff --git a/packages/expo/src/index.ts b/packages/expo/src/index.ts index aa9223501c0..1f4e0e6fb69 100644 --- a/packages/expo/src/index.ts +++ b/packages/expo/src/index.ts @@ -13,6 +13,7 @@ export { getClerkInstance } from './provider/singleton'; export * from './provider/ClerkProvider'; export * from './hooks'; export * from './components'; +export { UpdateRequiredView } from './update-required'; export * from './biometric-credentials/errors'; export * from './biometric-credentials/types'; export { useBiometricCredentials } from './biometric-credentials/deprecatedRootExport'; diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index b4020defd97..e4ed6847df3 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -4,12 +4,14 @@ import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react import { InternalClerkProvider as ClerkReactProvider, type Ui } from '@clerk/react/internal'; import { useEffect, useMemo, useRef } from 'react'; +import { useClerkRestartWhenSupported } from '../app-version/useClerkRestartWhenSupported'; import type { TokenCache } from '../cache/types'; +import { UpdateRequiredModal } from '../update-required/UpdateRequiredModal'; import { ClerkExpoModule } from '../utils/native-module'; import { isNative, isWeb } from '../utils/runtime'; import { maybeCompleteAuthSession } from './maybeCompleteAuthSession'; import { createNativeClientSync, type SyncableClerk } from './nativeClientSync'; -import { getClerkInstance } from './singleton'; +import { discardClerkInstance, getClerkInstance } from './singleton'; import type { BuildClerkOptions } from './singleton/types'; export type ClerkProviderProps = Omit, 'publishableKey'> & { @@ -50,6 +52,12 @@ export type ClerkProviderProps = Omit` over your app on iOS and Android when its version is older than the minimum set in the Clerk Dashboard. The view covers the whole app and can't be dismissed until the user updates. Set this to `false` to show your own UI from `useAppVersionSupport()` instead, or to place `` yourself. + * + * @default true + */ + updateRequiredViewEnabled?: boolean; }; const SDK_METADATA = { @@ -68,6 +76,7 @@ export function ClerkProvider(props: ClerkProviderProps(props: ClerkProviderProps nativeClientSync && clerkInstance @@ -111,7 +122,7 @@ export function ClerkProvider(props: ClerkProviderProps(props: ClerkProviderProps {children} + {isNative() && updateRequiredViewEnabled && } ); } diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx index 3af4094a01f..304b1f3c0c0 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx @@ -11,6 +11,18 @@ vi.mock('react-native', () => ({ Platform: { OS: 'ios', constants: { reactNativeVersion: { major: 0, minor: 81, patch: 0 } } }, })); +vi.mock('../../../app-version/appVersionRequestHooks', () => ({ + createAppVersionRequestHooks: () => ({ + refreshOnForegroundWhileUnsupported: () => {}, + beforeRequest: () => {}, + afterResponse: () => {}, + }), +})); + +vi.mock('../../../update-required/UpdateRequiredModal', () => ({ + UpdateRequiredModal: () => null, +})); + vi.mock('expo-secure-store', () => ({ AFTER_FIRST_UNLOCK: 0, deleteItemAsync: vi.fn(), @@ -19,6 +31,7 @@ vi.mock('expo-secure-store', () => ({ })); vi.mock('../../../utils/runtime', () => ({ + isExpoGo: () => false, isNative: () => true, isWeb: () => false, })); @@ -30,7 +43,7 @@ vi.mock('@clerk/react/internal', async () => { vi.mock('../../singleton', async () => { const { getClerkInstanceForActiveEnvironment } = await import('./environment'); - return { getClerkInstance: getClerkInstanceForActiveEnvironment }; + return { discardClerkInstance: () => {}, getClerkInstance: getClerkInstanceForActiveEnvironment }; }); vi.mock('../../../specs/NativeClerkModule', async () => { diff --git a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts index c5458adeb8d..fc2c9b5b590 100644 --- a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts +++ b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts @@ -39,6 +39,17 @@ vi.mock('react-native', () => { }; }); +vi.mock('../../../app-version/appVersionRequestHooks', () => ({ + createAppVersionRequestHooks: () => ({ + refreshOnForegroundWhileUnsupported: () => {}, + beforeRequest: ({ headers }: { headers: Headers }) => { + headers.set('x-bundle-id', 'com.example.app'); + headers.set('x-app-version', '4.8.4'); + }, + afterResponse: () => {}, + }), +})); + class MockClerk { public publishableKey: string; public proxyUrl: unknown; @@ -386,6 +397,32 @@ describe('createClerkInstance', () => { await expect(tokenCache.getToken(CLERK_CLIENT_JWT_KEY)).resolves.toBe('fresh-token'); }); + test('creates a new Clerk instance after the current one is discarded', async () => { + const mod = await import('../createClerkInstance'); + const getClerkInstance = mod.createClerkInstance(MockClerk as unknown as typeof Clerk); + const first = getClerkInstance({ publishableKey: 'pk_test_123' }); + + mod.discardClerkInstance(); + const second = getClerkInstance({ publishableKey: 'pk_test_123' }); + + expect(second).not.toBe(first); + expect(getClerkInstance({ publishableKey: 'pk_test_123' })).toBe(second); + }); + + test('identifies the app without changing the API version', async () => { + const { beforeRequest, requestInit } = await setUpBeforeRequest({ + getToken: vi.fn().mockResolvedValue(null), + saveToken: vi.fn(), + }); + requestInit.url.searchParams.set('__clerk_api_version', '2026-08-20'); + + await beforeRequest(requestInit); + + expect(requestInit.url.searchParams.getAll('__clerk_api_version')).toEqual(['2026-08-20']); + expect(requestInit.headers.get('x-bundle-id')).toBe('com.example.app'); + expect(requestInit.headers.get('x-app-version')).toBe('4.8.4'); + }); + test('waits until the app is active and retries when the token cache read fails in the background', async () => { setAppState('background'); const getToken = vi diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts index c0e523cb0be..276fd380808 100644 --- a/packages/expo/src/provider/singleton/createClerkInstance.ts +++ b/packages/expo/src/provider/singleton/createClerkInstance.ts @@ -10,6 +10,7 @@ import type { import { Platform } from 'react-native'; import packageJson from '../../../package.json'; +import { createAppVersionRequestHooks } from '../../app-version/appVersionRequestHooks'; import { ClientResourceCache, DUMMY_CLERK_CLIENT_RESOURCE, @@ -107,6 +108,10 @@ function getUpdatedClerkOptions( }; } +export function discardClerkInstance(): void { + __internal_clerk = undefined; +} + export function createClerkInstance(ClerkClass: typeof Clerk) { return (options?: BuildClerkOptions): HeadlessBrowserClerk | BrowserClerk => { const { __experimental_resourceCache: createResourceCache } = options || {}; @@ -140,10 +145,12 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { : tokenCache.saveToken(CLERK_CLIENT_JWT_KEY, token); }; const requestTokens = new WeakMap(); + const appVersionRequestHooks = isNative() ? createAppVersionRequestHooks() : null; __internal_clerkOptions = { publishableKey, proxyUrl, domain }; const clerk = new ClerkClass(publishableKey, { proxyUrl, domain }) as unknown as BrowserClerk; __internal_clerk = clerk; + appVersionRequestHooks?.refreshOnForegroundWhileUnsupported(() => clerk.__internal_reloadInitialResources()); if (Platform.OS === 'ios' || Platform.OS === 'android') { // @ts-expect-error - This is an internal API @@ -314,6 +321,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { if (isNative()) { (requestInit.headers as Headers).set('x-mobile', '1'); (requestInit.headers as Headers).set('x-expo-sdk-version', packageJson.version); + appVersionRequestHooks?.beforeRequest(requestInit); } }); @@ -325,6 +333,8 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { await saveClientToken(authHeader, requestTokens.get(requestInit) ?? null); } + appVersionRequestHooks?.afterResponse(requestInit, response); + if (__DEV__ && !nativeApiErrorShown && response.payload?.errors?.[0]?.code === 'native_api_disabled') { console.error( 'Clerk: The Native API is disabled for this instance.\nGo to Clerk Dashboard > Configure > Native applications to enable it.\nOr, navigate here: https://dashboard.clerk.com/~/native-applications', diff --git a/packages/expo/src/provider/singleton/singleton.ts b/packages/expo/src/provider/singleton/singleton.ts index eaf34ba0f3f..f4b23a774d2 100644 --- a/packages/expo/src/provider/singleton/singleton.ts +++ b/packages/expo/src/provider/singleton/singleton.ts @@ -2,6 +2,8 @@ import { Clerk } from '@clerk/clerk-js'; import { createClerkInstance } from './createClerkInstance'; +export { discardClerkInstance } from './createClerkInstance'; + /** * Access or create a Clerk instance outside of React. If you are using it in Expo Web then it will only access the existing instance from `window.Clerk` * @example diff --git a/packages/expo/src/provider/singleton/singleton.web.ts b/packages/expo/src/provider/singleton/singleton.web.ts index e931cabe7bb..59db5179b39 100644 --- a/packages/expo/src/provider/singleton/singleton.web.ts +++ b/packages/expo/src/provider/singleton/singleton.web.ts @@ -15,3 +15,5 @@ declare global { */ export const getClerkInstance = (_options?: BuildClerkOptions): HeadlessBrowserClerk | BrowserClerk | undefined => globalThis?.window?.Clerk; + +export const discardClerkInstance = (): void => {}; diff --git a/packages/expo/src/update-required/UpdateRequiredModal.tsx b/packages/expo/src/update-required/UpdateRequiredModal.tsx new file mode 100644 index 00000000000..66e0a9b38fe --- /dev/null +++ b/packages/expo/src/update-required/UpdateRequiredModal.tsx @@ -0,0 +1,18 @@ +import { Modal } from 'react-native'; + +import { useAppVersionSupport } from '../hooks/useAppVersionSupport'; +import { UpdateRequiredView } from './UpdateRequiredView'; + +export function UpdateRequiredModal(): JSX.Element { + const { isSupported } = useAppVersionSupport(); + return ( + {}} + > + + + ); +} diff --git a/packages/expo/src/update-required/UpdateRequiredView.tsx b/packages/expo/src/update-required/UpdateRequiredView.tsx new file mode 100644 index 00000000000..1da94be4690 --- /dev/null +++ b/packages/expo/src/update-required/UpdateRequiredView.tsx @@ -0,0 +1,312 @@ +import { useSyncExternalStore } from 'react'; +import { Image, Linking, Platform, Pressable, ScrollView, StyleSheet, Text, useColorScheme, View } from 'react-native'; + +import type { AppVersionSupportStatus, UpdateRequiredDisplay } from '../app-version/appVersionSupport'; +import { getUpdateRequiredDisplay, subscribeToAppVersionSupport } from '../app-version/appVersionSupport'; +import { getNativeAppIdentity, getNativeAppName } from '../app-version/nativeAppIdentity'; +import { useAppVersionSupport } from '../hooks/useAppVersionSupport'; +import { type UpdateRequiredColors, updateRequiredColors } from './colors'; + +const GOOGLE_PLAY_HOST = 'play.google.com'; +const MAX_ACTIONS_WIDTH = 320; + +/** + * A full-screen view telling the user to update the app because its version is older than the minimum set in the Clerk Dashboard. + * + * `` shows this view over your app automatically. To place it yourself, set `updateRequiredViewEnabled={false}` on `` and render it while `useAppVersionSupport()` reports the app as unsupported. + * + * @example + * ```tsx + * const { isSupported } = useAppVersionSupport(); + * if (!isSupported) { + * return ; + * } + * ``` + */ +export function UpdateRequiredView(): JSX.Element { + const status = useAppVersionSupport(); + const display = useSyncExternalStore( + subscribeToAppVersionSupport, + getUpdateRequiredDisplay, + getUpdateRequiredDisplay, + ); + const colors = updateRequiredColors(useColorScheme()); + + return ( + + ); +} + +type UpdateRequiredContentProps = { + status: AppVersionSupportStatus; + display: UpdateRequiredDisplay; + appName: string; + displayVersion: string | null; + colors: UpdateRequiredColors; +}; + +export function UpdateRequiredContent({ + status, + display, + appName, + displayVersion, + colors, +}: UpdateRequiredContentProps): JSX.Element { + const updateUrl = status.updateUrl; + const supportEmail = display.supportEmail; + + return ( + + + {display.logoImageUrl && ( + + )} + + + Update required + + + {`This version of ${appName} is no longer supported. To keep your account secure, please update to continue.`} + + + + + + + {updateUrl && ( + void Linking.openURL(updateUrl)} + style={({ pressed }) => [ + styles.primaryButton, + { backgroundColor: colors.primary, opacity: pressed ? 0.8 : 1 }, + ]} + > + + {storeButtonText(updateUrl)} + + + )} + {supportEmail && ( + void Linking.openURL(`mailto:${supportEmail}`)} + style={({ pressed }) => [styles.helpButton, { opacity: pressed ? 0.6 : 1 }]} + > + + {"Can't update? "} + {getHelpText()} + + + )} + + + ); +} + +function VersionBox({ + status, + displayVersion, + colors, +}: { + status: AppVersionSupportStatus; + displayVersion: string | null; + colors: UpdateRequiredColors; +}): JSX.Element | null { + const boxStyle = [styles.versionBox, { backgroundColor: colors.versionBoxBackground, borderColor: colors.border }]; + + if (Platform.OS === 'android') { + if (!displayVersion) { + return null; + } + return ( + + + + ); + } + + if (!status.minimumVersion || !status.currentVersion) { + return null; + } + return ( + + + + + + ); +} + +function VersionColumn({ + label, + version, + colors, +}: { + label: string; + version: string; + colors: UpdateRequiredColors; +}): JSX.Element { + return ( + + {label} + {version} + + ); +} + +export function storeButtonText(updateUrl: string): string { + if (Platform.OS === 'ios') { + return 'Go to App Store'; + } + try { + return new URL(updateUrl).hostname.toLowerCase() === GOOGLE_PLAY_HOST ? 'Go to Google Play' : 'Update app'; + } catch { + return 'Update app'; + } +} + +function getHelpText(): string { + return Platform.OS === 'ios' ? 'Get Help' : 'Get help'; +} + +const isIOS = Platform.OS === 'ios'; + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + scroll: { + flex: 1, + }, + content: { + alignItems: 'center', + gap: 24, + paddingHorizontal: 24, + paddingTop: isIOS ? 120 : 64, + paddingBottom: 24, + }, + logo: { + width: 44, + height: 44, + }, + header: { + alignItems: 'center', + gap: 8, + width: '100%', + }, + title: { + fontSize: 22, + lineHeight: 28, + fontWeight: isIOS ? '600' : '500', + textAlign: 'center', + }, + subtitle: { + fontSize: isIOS ? 15 : 16, + lineHeight: isIOS ? 20 : 24, + letterSpacing: isIOS ? 0 : 0.15, + textAlign: 'center', + }, + versionBox: { + flexDirection: 'row', + alignItems: 'stretch', + width: '100%', + padding: 16, + borderRadius: 12, + borderWidth: StyleSheet.hairlineWidth * 2, + }, + versionColumn: { + flex: 1, + alignItems: 'center', + gap: 2, + }, + versionDivider: { + width: 1, + }, + versionLabel: { + fontSize: 14, + lineHeight: 20, + fontWeight: '500', + letterSpacing: isIOS ? 0 : 0.1, + textAlign: 'center', + }, + versionValue: { + fontSize: 16, + lineHeight: 24, + fontWeight: '600', + letterSpacing: isIOS ? 0 : 0.15, + textAlign: 'center', + }, + actions: { + alignSelf: 'center', + width: '100%', + maxWidth: MAX_ACTIONS_WIDTH + 48, + paddingHorizontal: 24, + paddingTop: 16, + paddingBottom: isIOS ? 48 : 40, + gap: 12, + }, + primaryButton: { + height: 48, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 14, + }, + primaryButtonText: { + fontSize: 16, + lineHeight: 24, + fontWeight: '500', + letterSpacing: isIOS ? 0 : 0.15, + }, + helpButton: { + height: 32, + alignItems: 'center', + justifyContent: 'center', + }, + helpText: { + fontSize: 14, + lineHeight: 20, + fontWeight: '500', + }, + helpLink: { + fontWeight: '600', + }, +}); diff --git a/packages/expo/src/update-required/__tests__/UpdateRequiredView.test.tsx b/packages/expo/src/update-required/__tests__/UpdateRequiredView.test.tsx new file mode 100644 index 00000000000..b84e2589d87 --- /dev/null +++ b/packages/expo/src/update-required/__tests__/UpdateRequiredView.test.tsx @@ -0,0 +1,159 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + platformOS: 'ios' as string, + openURL: vi.fn(), + identity: null as null | { platform: string; appIdentifier: string; version: string; displayVersion: string }, +})); + +vi.mock('react-native', async () => { + const React = await import('react'); + type Props = { children?: ReactNode; onPress?: () => void; accessibilityRole?: string; source?: { uri: string } }; + const element = + (tag: string) => + ({ children, onPress, accessibilityRole, source }: Props) => + React.createElement(tag, { onClick: onPress, role: accessibilityRole, src: source?.uri }, children); + return { + View: element('div'), + ScrollView: element('div'), + Text: element('span'), + Pressable: element('button'), + Image: element('img'), + Modal: ({ visible, children }: { visible: boolean; children?: ReactNode }) => + visible ? React.createElement('div', { 'data-testid': 'modal' }, children) : null, + StyleSheet: { create: (styles: T) => styles, hairlineWidth: 0.5 }, + Linking: { openURL: mocks.openURL }, + useColorScheme: () => 'light', + Platform: { + get OS() { + return mocks.platformOS; + }, + }, + }; +}); + +vi.mock('../../app-version/nativeAppIdentity', () => ({ + getNativeAppIdentity: () => mocks.identity, + getNativeAppPlatform: () => mocks.platformOS, + getNativeAppName: () => 'Acme Native', + isExpoApplicationInstalled: () => true, +})); + +const APP_STORE_URL = 'https://apps.apple.com/app/id123'; +const PLAY_URL = 'https://play.google.com/store/apps/details?id=com.example.app'; + +const loadModules = async () => { + const store = await import('../../app-version/appVersionSupport'); + const { UpdateRequiredView } = await import('../UpdateRequiredView'); + const { UpdateRequiredModal } = await import('../UpdateRequiredModal'); + return { store, UpdateRequiredView, UpdateRequiredModal }; +}; + +const environment = ({ updateUrl, supportEmail }: { updateUrl: string; supportEmail?: string }) => ({ + display_config: { + application_name: 'Acme', + logo_image_url: 'https://img.example.com/logo.png', + support_email: supportEmail, + }, + native_app_settings: { + minimum_supported_version: { + ios: [{ bundle_id: 'com.example.app', minimum_version: '2.0', update_url: updateUrl }], + android: [{ package_name: 'com.example.app', minimum_version: '42', update_url: updateUrl }], + }, + }, +}); + +describe('UpdateRequiredView', () => { + beforeEach(() => { + vi.resetModules(); + mocks.openURL.mockReset(); + mocks.platformOS = 'ios'; + mocks.identity = { platform: 'ios', appIdentifier: 'com.example.app', version: '1.9.9', displayVersion: '1.9.9' }; + }); + + const block = ( + store: Awaited>['store'], + options: { updateUrl: string; supportEmail?: string }, + ) => { + act(() => { + store.applyFetchedEnvironment(environment(options), store.getAppVersionSupportGeneration()); + }); + }; + + test('tells an iOS user to update and compares their version with the minimum', async () => { + const { store, UpdateRequiredView } = await loadModules(); + render(); + block(store, { updateUrl: APP_STORE_URL, supportEmail: 'support@example.com' }); + + expect(screen.getByRole('header').textContent).toBe('Update required'); + expect( + screen.getByText( + 'This version of Acme is no longer supported. To keep your account secure, please update to continue.', + ), + ).toBeDefined(); + expect(screen.getByText('Your version')).toBeDefined(); + expect(screen.getByText('1.9.9')).toBeDefined(); + expect(screen.getByText('2.0+')).toBeDefined(); + expect(document.querySelector('img')?.getAttribute('src')).toBe('https://img.example.com/logo.png'); + }); + + test('opens the store and the support email', async () => { + const { store, UpdateRequiredView } = await loadModules(); + render(); + block(store, { updateUrl: APP_STORE_URL, supportEmail: 'support@example.com' }); + + fireEvent.click(screen.getByText('Go to App Store')); + fireEvent.click(screen.getByText('Get Help')); + + expect(mocks.openURL).toHaveBeenNthCalledWith(1, APP_STORE_URL); + expect(mocks.openURL).toHaveBeenNthCalledWith(2, 'mailto:support@example.com'); + }); + + test('leaves out the help link without a support email', async () => { + const { store, UpdateRequiredView } = await loadModules(); + render(); + block(store, { updateUrl: APP_STORE_URL }); + + expect(screen.queryByText('Get Help')).toBeNull(); + }); + + test('shows an Android user only their version name', async () => { + mocks.platformOS = 'android'; + mocks.identity = { platform: 'android', appIdentifier: 'com.example.app', version: '41', displayVersion: '4.8.4' }; + const { store, UpdateRequiredView } = await loadModules(); + render(); + block(store, { updateUrl: PLAY_URL, supportEmail: 'support@example.com' }); + + expect(screen.getByText('4.8.4')).toBeDefined(); + expect(screen.queryByText('Required')).toBeNull(); + expect(screen.queryByText('41')).toBeNull(); + expect(screen.getByText('Go to Google Play')).toBeDefined(); + expect(screen.getByText('Get help')).toBeDefined(); + }); + + test('names the Android button after a store other than Google Play', async () => { + mocks.platformOS = 'android'; + mocks.identity = { platform: 'android', appIdentifier: 'com.example.app', version: '41', displayVersion: '4.8.4' }; + const { store, UpdateRequiredView } = await loadModules(); + render(); + block(store, { updateUrl: 'https://galaxystore.samsung.com/detail/com.example.app' }); + + expect(screen.getByText('Update app')).toBeDefined(); + }); + + test('the modal covers the app only while it is unsupported', async () => { + const { store, UpdateRequiredModal } = await loadModules(); + render(); + expect(screen.queryByTestId('modal')).toBeNull(); + + block(store, { updateUrl: APP_STORE_URL }); + expect(screen.getByTestId('modal')).toBeDefined(); + + act(() => { + store.applySupportedAppVersion(store.getAppVersionSupportGeneration()); + }); + expect(screen.queryByTestId('modal')).toBeNull(); + }); +}); diff --git a/packages/expo/src/update-required/colors.ts b/packages/expo/src/update-required/colors.ts new file mode 100644 index 00000000000..cb0c2c13d38 --- /dev/null +++ b/packages/expo/src/update-required/colors.ts @@ -0,0 +1,35 @@ +import type { ColorSchemeName } from 'react-native'; + +export type UpdateRequiredColors = { + background: string; + foreground: string; + mutedForeground: string; + primary: string; + primaryForeground: string; + border: string; + versionBoxBackground: string; +}; + +const LIGHT: UpdateRequiredColors = { + background: '#FFFFFF', + foreground: '#212126', + mutedForeground: '#747686', + primary: '#2F3037', + primaryForeground: '#FFFFFF', + border: 'rgba(0, 0, 0, 0.06)', + versionBoxBackground: 'rgba(0, 0, 0, 0.02)', +}; + +const DARK: UpdateRequiredColors = { + background: '#131316', + foreground: '#FFFFFF', + mutedForeground: '#B7B8C2', + primary: '#FAFAFB', + primaryForeground: '#000000', + border: 'rgba(255, 255, 255, 0.08)', + versionBoxBackground: 'rgba(255, 255, 255, 0.03)', +}; + +export function updateRequiredColors(colorScheme: ColorSchemeName): UpdateRequiredColors { + return colorScheme === 'dark' ? DARK : LIGHT; +} diff --git a/packages/expo/src/update-required/index.ts b/packages/expo/src/update-required/index.ts new file mode 100644 index 00000000000..736455cbce8 --- /dev/null +++ b/packages/expo/src/update-required/index.ts @@ -0,0 +1 @@ +export { UpdateRequiredView } from './UpdateRequiredView'; diff --git a/packages/expo/src/utils/runtime.ts b/packages/expo/src/utils/runtime.ts index 2b81407bddf..39fb03431de 100644 --- a/packages/expo/src/utils/runtime.ts +++ b/packages/expo/src/utils/runtime.ts @@ -26,6 +26,21 @@ export function waitUntilAppIsActive(): Promise { }); } +export function isExpoGo(): boolean { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const constants = (require('expo-constants') as { default?: ExpoGoConstants }).default; + return constants?.executionEnvironment === 'storeClient' || constants?.appOwnership === 'expo'; + } catch { + return false; + } +} + +type ExpoGoConstants = { + appOwnership?: string | null; + executionEnvironment?: string; +}; + export function isHermes() { //@ts-expect-error HermesInternal is added only added by the Hermes JS Engine return !!global.HermesInternal; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 032116ad7a8..4ca7d1373f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -695,6 +695,9 @@ importers: expo-apple-authentication: specifier: ^8.0.8 version: 8.0.8(expo@54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10)) + expo-application: + specifier: ^7.0.8 + version: 7.0.8(expo@54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)) expo-auth-session: specifier: ^7.0.11 version: 7.0.11(expo@54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)