From 6da3129949c1fb30488d78298a88a749ea29081b Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 21 Oct 2026 15:17:15 -0400 Subject: [PATCH 1/9] feat(expo): block app versions older than the minimum set in the Clerk Dashboard Co-Authored-By: Claude Opus 5.5 --- .../expo-native-app-minimum-versions.md | 9 + packages/expo/package.json | 5 + .../__tests__/appVersionRequestHooks.test.ts | 125 +++++++ .../__tests__/appVersionSupport.test.ts | 243 +++++++++++++ .../__tests__/nativeAppIdentity.test.ts | 116 +++++++ .../src/app-version/appVersionRequestHooks.ts | 68 ++++ .../expo/src/app-version/appVersionSupport.ts | 260 ++++++++++++++ .../expo/src/app-version/nativeAppIdentity.ts | 72 ++++ packages/expo/src/constants.ts | 2 + .../__tests__/useAppVersionSupport.test.ts | 32 ++ packages/expo/src/hooks/index.ts | 1 + .../expo/src/hooks/useAppVersionSupport.ts | 25 ++ packages/expo/src/hooks/useHostedAuth.ts | 8 +- packages/expo/src/index.ts | 1 + packages/expo/src/provider/ClerkProvider.tsx | 9 + .../nativeClientSync.behavior.test.tsx | 5 + .../__tests__/createClerkInstance.test.ts | 24 ++ .../provider/singleton/createClerkInstance.ts | 8 +- .../update-required/UpdateRequiredModal.tsx | 23 ++ .../update-required/UpdateRequiredView.tsx | 319 ++++++++++++++++++ .../__tests__/UpdateRequiredView.test.tsx | 162 +++++++++ packages/expo/src/update-required/colors.ts | 35 ++ packages/expo/src/update-required/index.ts | 1 + packages/expo/src/utils/runtime.ts | 15 + pnpm-lock.yaml | 9 +- 25 files changed, 1568 insertions(+), 9 deletions(-) create mode 100644 .changeset/expo-native-app-minimum-versions.md create mode 100644 packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts create mode 100644 packages/expo/src/app-version/__tests__/appVersionSupport.test.ts create mode 100644 packages/expo/src/app-version/__tests__/nativeAppIdentity.test.ts create mode 100644 packages/expo/src/app-version/appVersionRequestHooks.ts create mode 100644 packages/expo/src/app-version/appVersionSupport.ts create mode 100644 packages/expo/src/app-version/nativeAppIdentity.ts create mode 100644 packages/expo/src/hooks/__tests__/useAppVersionSupport.test.ts create mode 100644 packages/expo/src/hooks/useAppVersionSupport.ts create mode 100644 packages/expo/src/update-required/UpdateRequiredModal.tsx create mode 100644 packages/expo/src/update-required/UpdateRequiredView.tsx create mode 100644 packages/expo/src/update-required/__tests__/UpdateRequiredView.test.tsx create mode 100644 packages/expo/src/update-required/colors.ts create mode 100644 packages/expo/src/update-required/index.ts diff --git a/.changeset/expo-native-app-minimum-versions.md b/.changeset/expo-native-app-minimum-versions.md new file mode 100644 index 00000000000..b8ed9be9730 --- /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. Requests from iOS and Android now use Clerk API version `2026-10-09`. 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..0cfff494862 --- /dev/null +++ b/packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts @@ -0,0 +1,125 @@ +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, + 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(); + }); + + 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 during 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(environmentRequest); + hooks.beforeRequest(clientRequest); + + hooks.afterResponse(clientRequest, unsupportedAppVersion); + 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); + }); +}); 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..23313da7cf0 --- /dev/null +++ b/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts @@ -0,0 +1,243 @@ +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, { + generation: store.getAppVersionSupportGeneration(), + serverAnswersAtRequest: store.getServerAnswerCount(), + }); + }; + + 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 requested after a rejection lifts it', async () => { + const store = await loadStore(); + store.applyUnsupportedAppVersion(null, store.getAppVersionSupportGeneration()); + + fetchEnvironment(store, environment()); + + expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + }); + + test('an environment in flight during a rejection keeps the block', async () => { + const store = await loadStore(); + const serverAnswersAtRequest = store.getServerAnswerCount(); + const generation = store.getAppVersionSupportGeneration(); + + store.applyUnsupportedAppVersion({ minimum_version: '2.0' }, generation); + store.applyFetchedEnvironment(environment(), { generation, serverAnswersAtRequest }); + + expect(store.getAppVersionSupportStatus().isSupported).toBe(false); + }); + + 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/appVersionRequestHooks.ts b/packages/expo/src/app-version/appVersionRequestHooks.ts new file mode 100644 index 00000000000..785aa282083 --- /dev/null +++ b/packages/expo/src/app-version/appVersionRequestHooks.ts @@ -0,0 +1,68 @@ +import { + applyFetchedEnvironment, + applySupportedAppVersion, + applyUnsupportedAppVersion, + getAppVersionSupportGeneration, + getServerAnswerCount, + 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; +}; + +export function createAppVersionRequestHooks() { + resetAppVersionSupport(); + const generation = getAppVersionSupportGeneration(); + const serverAnswersAtEnvironmentRequest = new WeakMap(); + + return { + beforeRequest(requestInit: AppVersionRequest) { + setNativeAppIdentityHeaders(requestInit.headers as Headers); + if (requestInit.path === ENVIRONMENT_PATH) { + serverAnswersAtEnvironmentRequest.set(requestInit, getServerAnswerCount()); + } + }, + 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); + } + const serverAnswersAtRequest = serverAnswersAtEnvironmentRequest.get(requestInit); + if (response.ok && serverAnswersAtRequest !== undefined) { + applyFetchedEnvironment(environmentJSON(response.payload), { generation, serverAnswersAtRequest }); + } + }, + }; +} + +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; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/expo/src/app-version/appVersionSupport.ts b/packages/expo/src/app-version/appVersionSupport.ts new file mode 100644 index 00000000000..50003fdf8ec --- /dev/null +++ b/packages/expo/src/app-version/appVersionSupport.ts @@ -0,0 +1,260 @@ +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 serverStatus: AppVersionSupportStatus | null = null; +let serverAnswerCount = 0; +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 getServerAnswerCount(): number { + return serverAnswerCount; +} + +export function resetAppVersionSupport(): void { + generation += 1; + environmentStatus = null; + serverStatus = null; + display = EMPTY_DISPLAY; + publish({ displayChanged: true }); +} + +export function applyFetchedEnvironment( + environment: unknown, + { generation: requestGeneration, serverAnswersAtRequest }: { generation: number; serverAnswersAtRequest: number }, +): void { + if (requestGeneration !== generation) { + return; + } + if (serverAnswersAtRequest === serverAnswerCount) { + serverStatus = null; + } + 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; + } + serverAnswerCount += 1; + const fields = isRecord(meta) ? meta : {}; + serverStatus = { + 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; + } + serverAnswerCount += 1; + serverStatus = supportedStatus(); + 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 comparison = minimumVersion ? compareVersions(identity, minimumVersion) : null; + return { + isSupported: comparison === null || comparison >= 0, + currentVersion: identity.version, + minimumVersion: comparison === 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 compareVersions(identity: NativeAppIdentity, minimumVersion: string): number | 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; + } + } + return 0; +} + +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 = serverStatus ?? 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; +} + +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/constants.ts b/packages/expo/src/constants.ts index 9b36b334fbb..c116c43e5b9 100644 --- a/packages/expo/src/constants.ts +++ b/packages/expo/src/constants.ts @@ -1,3 +1,5 @@ // Token cache key used by the Clerk JS SDK for storing the client JWT. // This must be consistent across all usages (ClerkProvider, native views, etc.) export const CLERK_CLIENT_JWT_KEY = '__clerk_client_jwt'; + +export const NATIVE_APP_FAPI_VERSION = '2026-10-09'; 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..d108c88f441 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -5,6 +5,7 @@ import { InternalClerkProvider as ClerkReactProvider, type Ui } from '@clerk/rea import { useEffect, useMemo, useRef } from 'react'; 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'; @@ -50,6 +51,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 +75,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..c068d6d4893 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,10 @@ vi.mock('react-native', () => ({ Platform: { OS: 'ios', constants: { reactNativeVersion: { major: 0, minor: 81, patch: 0 } } }, })); +vi.mock('../../../update-required/UpdateRequiredModal', () => ({ + UpdateRequiredModal: () => null, +})); + vi.mock('expo-secure-store', () => ({ AFTER_FIRST_UNLOCK: 0, deleteItemAsync: vi.fn(), @@ -19,6 +23,7 @@ vi.mock('expo-secure-store', () => ({ })); vi.mock('../../../utils/runtime', () => ({ + isExpoGo: () => false, isNative: () => true, isWeb: () => false, })); diff --git a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts index c5458adeb8d..38bbcffe414 100644 --- a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts +++ b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts @@ -39,6 +39,16 @@ vi.mock('react-native', () => { }; }); +vi.mock('../../../app-version/appVersionRequestHooks', () => ({ + createAppVersionRequestHooks: () => ({ + 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 +396,20 @@ describe('createClerkInstance', () => { await expect(tokenCache.getToken(CLERK_CLIENT_JWT_KEY)).resolves.toBe('fresh-token'); }); + test('identifies the app and asks for the API version that enforces minimum versions', 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-10-09']); + 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..ca07c26e830 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, @@ -21,7 +22,7 @@ import { } from '../../cache'; import { MemoryTokenCache } from '../../cache/MemoryTokenCache'; import type { TokenCache } from '../../cache/types'; -import { CLERK_CLIENT_JWT_KEY } from '../../constants'; +import { CLERK_CLIENT_JWT_KEY, NATIVE_APP_FAPI_VERSION } from '../../constants'; import { errorThrower } from '../../errorThrower'; import { assertValidProxyUrl } from '../../utils/errors'; import { isNative, waitUntilAppIsActive } from '../../utils/runtime'; @@ -140,6 +141,7 @@ 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; @@ -314,6 +316,8 @@ 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); + requestInit.url?.searchParams.set('__clerk_api_version', NATIVE_APP_FAPI_VERSION); + appVersionRequestHooks?.beforeRequest(requestInit); } }); @@ -325,6 +329,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/update-required/UpdateRequiredModal.tsx b/packages/expo/src/update-required/UpdateRequiredModal.tsx new file mode 100644 index 00000000000..f558dca9bdf --- /dev/null +++ b/packages/expo/src/update-required/UpdateRequiredModal.tsx @@ -0,0 +1,23 @@ +import { useSyncExternalStore } from 'react'; +import { Modal } from 'react-native'; + +import { getAppVersionSupportStatus, subscribeToAppVersionSupport } from '../app-version/appVersionSupport'; +import { UpdateRequiredView } from './UpdateRequiredView'; + +export function UpdateRequiredModal(): JSX.Element { + const status = useSyncExternalStore( + subscribeToAppVersionSupport, + getAppVersionSupportStatus, + getAppVersionSupportStatus, + ); + 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..09a7452222a --- /dev/null +++ b/packages/expo/src/update-required/UpdateRequiredView.tsx @@ -0,0 +1,319 @@ +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 { + getAppVersionSupportStatus, + getUpdateRequiredDisplay, + subscribeToAppVersionSupport, +} from '../app-version/appVersionSupport'; +import { getNativeAppIdentity, getNativeAppName } from '../app-version/nativeAppIdentity'; +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 = useSyncExternalStore( + subscribeToAppVersionSupport, + getAppVersionSupportStatus, + getAppVersionSupportStatus, + ); + 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..4e57366e3d3 --- /dev/null +++ b/packages/expo/src/update-required/__tests__/UpdateRequiredView.test.tsx @@ -0,0 +1,162 @@ +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), { + generation: store.getAppVersionSupportGeneration(), + serverAnswersAtRequest: store.getServerAnswerCount(), + }); + }); + }; + + 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..33066eaa06c 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) @@ -22040,9 +22043,9 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0)) + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0))(vitest@5.0.0) + '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0))(vitest@5.0.0) '@vitest/expect@5.0.2': dependencies: @@ -22109,7 +22112,7 @@ snapshots: pathe: 2.0.3 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0)) + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0)) '@vitest/utils@5.0.0': dependencies: From bc4b7c803047ea7cfef810f03e43c49810071b8f Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 21 Oct 2026 15:45:40 -0400 Subject: [PATCH 2/9] fix(expo): reload when a blocked app returns to the foreground Token responses minted at the edge carry no app version status, so a blocked app only learns that it's supported again from a fresh environment, as the native SDKs do on foreground. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/appVersionRequestHooks.test.ts | 42 +++++++++++++++++++ .../src/app-version/appVersionRequestHooks.ts | 14 +++++++ .../nativeClientSync.behavior.test.tsx | 8 ++++ .../__tests__/createClerkInstance.test.ts | 1 + .../provider/singleton/createClerkInstance.ts | 5 +++ 5 files changed, 70 insertions(+) diff --git a/packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts b/packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts index 0cfff494862..2267d8b5ccd 100644 --- a/packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts +++ b/packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts @@ -1,5 +1,19 @@ 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', @@ -45,6 +59,7 @@ const loadModules = async () => { describe('createAppVersionRequestHooks', () => { beforeEach(() => { vi.resetModules(); + appState.listeners.clear(); }); test('identifies the app on every request', async () => { @@ -122,4 +137,31 @@ describe('createAppVersionRequestHooks', () => { 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/appVersionRequestHooks.ts b/packages/expo/src/app-version/appVersionRequestHooks.ts index 785aa282083..dea2d30cf8b 100644 --- a/packages/expo/src/app-version/appVersionRequestHooks.ts +++ b/packages/expo/src/app-version/appVersionRequestHooks.ts @@ -1,8 +1,11 @@ +import { AppState, type NativeEventSubscription } from 'react-native'; + import { applyFetchedEnvironment, applySupportedAppVersion, applyUnsupportedAppVersion, getAppVersionSupportGeneration, + getAppVersionSupportStatus, getServerAnswerCount, resetAppVersionSupport, } from './appVersionSupport'; @@ -17,6 +20,8 @@ type AppVersionRequest = { headers?: HeadersInit; }; +let foregroundSubscription: NativeEventSubscription | null = null; + type AppVersionResponse = { ok: boolean; status: number; @@ -26,10 +31,19 @@ type AppVersionResponse = { export function createAppVersionRequestHooks() { resetAppVersionSupport(); + foregroundSubscription?.remove(); + foregroundSubscription = null; const generation = getAppVersionSupportGeneration(); const serverAnswersAtEnvironmentRequest = new WeakMap(); 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); if (requestInit.path === ENVIRONMENT_PATH) { 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 c068d6d4893..f708af7b53f 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,14 @@ 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, })); diff --git a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts index 38bbcffe414..d4c28d5468e 100644 --- a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts +++ b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts @@ -41,6 +41,7 @@ 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'); diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts index ca07c26e830..4e6259eebf4 100644 --- a/packages/expo/src/provider/singleton/createClerkInstance.ts +++ b/packages/expo/src/provider/singleton/createClerkInstance.ts @@ -146,6 +146,11 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { __internal_clerkOptions = { publishableKey, proxyUrl, domain }; const clerk = new ClerkClass(publishableKey, { proxyUrl, domain }) as unknown as BrowserClerk; __internal_clerk = clerk; + appVersionRequestHooks?.refreshOnForegroundWhileUnsupported(async () => { + if (clerk.loaded) { + await clerk.__internal_reloadInitialResources(); + } + }); if (Platform.OS === 'ios' || Platform.OS === 'android') { // @ts-expect-error - This is an internal API From 772f3273e4c5500df13d13f1fc9ad9398cc18753 Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 21 Oct 2026 15:51:31 -0400 Subject: [PATCH 3/9] chore(expo): dedupe the lockfile Co-Authored-By: Claude Opus 5.5 --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33066eaa06c..4ca7d1373f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22043,9 +22043,9 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0)) + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0))(vitest@5.0.0) + '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0))(vitest@5.0.0) '@vitest/expect@5.0.2': dependencies: @@ -22112,7 +22112,7 @@ snapshots: pathe: 2.0.3 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0)) + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0)) '@vitest/utils@5.0.0': dependencies: From 1eeded4113cc1c0198f8b8cef20d4ac00215e5aa Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 21 Oct 2026 17:03:12 -0400 Subject: [PATCH 4/9] fix(expo): restart Clerk when the app version block lifts after a failed load Co-Authored-By: Claude Opus 5.5 --- .../useClerkRestartWhenSupported.test.ts | 68 +++++++++++++++++++ .../useClerkRestartWhenSupported.ts | 30 ++++++++ packages/expo/src/provider/ClerkProvider.tsx | 7 +- .../nativeClientSync.behavior.test.tsx | 2 +- .../__tests__/createClerkInstance.test.ts | 12 ++++ .../provider/singleton/createClerkInstance.ts | 10 +-- .../expo/src/provider/singleton/singleton.ts | 2 + .../src/provider/singleton/singleton.web.ts | 2 + 8 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 packages/expo/src/app-version/__tests__/useClerkRestartWhenSupported.test.ts create mode 100644 packages/expo/src/app-version/useClerkRestartWhenSupported.ts 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/useClerkRestartWhenSupported.ts b/packages/expo/src/app-version/useClerkRestartWhenSupported.ts new file mode 100644 index 00000000000..c1138c1ed44 --- /dev/null +++ b/packages/expo/src/app-version/useClerkRestartWhenSupported.ts @@ -0,0 +1,30 @@ +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; + +import { getAppVersionSupportStatus, subscribeToAppVersionSupport } from './appVersionSupport'; + +type LoadableClerk = { status?: string } | null; + +export function useClerkRestartWhenSupported(clerk: LoadableClerk, discardClerk: () => void): number { + const { isSupported } = useSyncExternalStore( + subscribeToAppVersionSupport, + getAppVersionSupportStatus, + getAppVersionSupportStatus, + ); + 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/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index d108c88f441..e4ed6847df3 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -4,13 +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'> & { @@ -99,6 +100,8 @@ export function ClerkProvider(props: ClerkProviderProps nativeClientSync && clerkInstance @@ -119,7 +122,7 @@ export function ClerkProvider(props: ClerkProviderProps { 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 d4c28d5468e..a0e2b730fcd 100644 --- a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts +++ b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts @@ -397,6 +397,18 @@ 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 and asks for the API version that enforces minimum versions', async () => { const { beforeRequest, requestInit } = await setUpBeforeRequest({ getToken: vi.fn().mockResolvedValue(null), diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts index 4e6259eebf4..cf478cd1f4f 100644 --- a/packages/expo/src/provider/singleton/createClerkInstance.ts +++ b/packages/expo/src/provider/singleton/createClerkInstance.ts @@ -108,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 || {}; @@ -146,11 +150,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { __internal_clerkOptions = { publishableKey, proxyUrl, domain }; const clerk = new ClerkClass(publishableKey, { proxyUrl, domain }) as unknown as BrowserClerk; __internal_clerk = clerk; - appVersionRequestHooks?.refreshOnForegroundWhileUnsupported(async () => { - if (clerk.loaded) { - await clerk.__internal_reloadInitialResources(); - } - }); + appVersionRequestHooks?.refreshOnForegroundWhileUnsupported(() => clerk.__internal_reloadInitialResources()); if (Platform.OS === 'ios' || Platform.OS === 'android') { // @ts-expect-error - This is an internal API 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 => {}; From 9ac330808702f8b5fa5cb1375e18bcdcacfbdd48 Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 21 Oct 2026 20:42:59 -0400 Subject: [PATCH 5/9] fix(expo): keep the default API version, since minimum versions apply on every version Co-Authored-By: Claude Opus 5.5 --- .changeset/expo-native-app-minimum-versions.md | 2 +- packages/expo/src/constants.ts | 2 -- .../provider/singleton/__tests__/createClerkInstance.test.ts | 4 ++-- packages/expo/src/provider/singleton/createClerkInstance.ts | 3 +-- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.changeset/expo-native-app-minimum-versions.md b/.changeset/expo-native-app-minimum-versions.md index b8ed9be9730..a2669bc9a75 100644 --- a/.changeset/expo-native-app-minimum-versions.md +++ b/.changeset/expo-native-app-minimum-versions.md @@ -6,4 +6,4 @@ Support the minimum app versions set in the Clerk Dashboard. When your iOS or An 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. Requests from iOS and Android now use Clerk API version `2026-10-09`. Apps without `expo-application`, apps in Expo Go, and web apps are never blocked. +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/src/constants.ts b/packages/expo/src/constants.ts index c116c43e5b9..9b36b334fbb 100644 --- a/packages/expo/src/constants.ts +++ b/packages/expo/src/constants.ts @@ -1,5 +1,3 @@ // Token cache key used by the Clerk JS SDK for storing the client JWT. // This must be consistent across all usages (ClerkProvider, native views, etc.) export const CLERK_CLIENT_JWT_KEY = '__clerk_client_jwt'; - -export const NATIVE_APP_FAPI_VERSION = '2026-10-09'; diff --git a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts index a0e2b730fcd..fc2c9b5b590 100644 --- a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts +++ b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts @@ -409,7 +409,7 @@ describe('createClerkInstance', () => { expect(getClerkInstance({ publishableKey: 'pk_test_123' })).toBe(second); }); - test('identifies the app and asks for the API version that enforces minimum versions', async () => { + test('identifies the app without changing the API version', async () => { const { beforeRequest, requestInit } = await setUpBeforeRequest({ getToken: vi.fn().mockResolvedValue(null), saveToken: vi.fn(), @@ -418,7 +418,7 @@ describe('createClerkInstance', () => { await beforeRequest(requestInit); - expect(requestInit.url.searchParams.getAll('__clerk_api_version')).toEqual(['2026-10-09']); + 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'); }); diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts index cf478cd1f4f..276fd380808 100644 --- a/packages/expo/src/provider/singleton/createClerkInstance.ts +++ b/packages/expo/src/provider/singleton/createClerkInstance.ts @@ -22,7 +22,7 @@ import { } from '../../cache'; import { MemoryTokenCache } from '../../cache/MemoryTokenCache'; import type { TokenCache } from '../../cache/types'; -import { CLERK_CLIENT_JWT_KEY, NATIVE_APP_FAPI_VERSION } from '../../constants'; +import { CLERK_CLIENT_JWT_KEY } from '../../constants'; import { errorThrower } from '../../errorThrower'; import { assertValidProxyUrl } from '../../utils/errors'; import { isNative, waitUntilAppIsActive } from '../../utils/runtime'; @@ -321,7 +321,6 @@ 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); - requestInit.url?.searchParams.set('__clerk_api_version', NATIVE_APP_FAPI_VERSION); appVersionRequestHooks?.beforeRequest(requestInit); } }); From 2da5e0ca6699d58c8f131c33440ca86595c08362 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 22 Oct 2026 12:27:47 -0400 Subject: [PATCH 6/9] fix(expo): let only a rejection outlive an environment fetch Co-Authored-By: Claude Opus 5.5 --- .../app-version/__tests__/appVersionSupport.test.ts | 12 ++++++++++++ packages/expo/src/app-version/appVersionSupport.ts | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts b/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts index 23313da7cf0..395fa10a40d 100644 --- a/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts +++ b/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts @@ -190,6 +190,18 @@ describe('appVersionSupport', () => { expect(store.getAppVersionSupportStatus().isSupported).toBe(false); }); + test('a supported answer during the fetch does not hide the environment block', async () => { + const store = await loadStore(); + const serverAnswersAtRequest = store.getServerAnswerCount(); + const generation = store.getAppVersionSupportGeneration(); + + store.applySupportedAppVersion(generation); + store.applyFetchedEnvironment(environment({ ios: [iosPolicy('2.0')] }), { generation, serverAnswersAtRequest }); + + expect(store.getAppVersionSupportStatus().isSupported).toBe(false); + expect(store.getAppVersionSupportStatus().minimumVersion).toBe('2.0'); + }); + test('ignores answers for a Clerk instance that was replaced', async () => { const store = await loadStore(); const staleGeneration = store.getAppVersionSupportGeneration(); diff --git a/packages/expo/src/app-version/appVersionSupport.ts b/packages/expo/src/app-version/appVersionSupport.ts index 50003fdf8ec..7ad752d1362 100644 --- a/packages/expo/src/app-version/appVersionSupport.ts +++ b/packages/expo/src/app-version/appVersionSupport.ts @@ -86,7 +86,8 @@ export function applyFetchedEnvironment( if (requestGeneration !== generation) { return; } - if (serverAnswersAtRequest === serverAnswerCount) { + const answeredDuringFetch = serverAnswersAtRequest !== serverAnswerCount; + if (!answeredDuringFetch || serverStatus?.isSupported !== false) { serverStatus = null; } environmentStatus = resolveEnvironmentStatus(environment); From 1fc8c09ab9b7f13147b6de473a353cd8483d245e Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 22 Oct 2026 13:12:04 -0400 Subject: [PATCH 7/9] fix(expo): keep the environment's minimum when a response marks the app supported Co-Authored-By: Claude Opus 5.5 --- .../__tests__/appVersionSupport.test.ts | 29 +++++++++++++++++++ .../expo/src/app-version/appVersionSupport.ts | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts b/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts index 395fa10a40d..d99bcf1f448 100644 --- a/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts +++ b/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts @@ -202,6 +202,35 @@ describe('appVersionSupport', () => { expect(store.getAppVersionSupportStatus().minimumVersion).toBe('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(); diff --git a/packages/expo/src/app-version/appVersionSupport.ts b/packages/expo/src/app-version/appVersionSupport.ts index 7ad752d1362..56410d79f79 100644 --- a/packages/expo/src/app-version/appVersionSupport.ts +++ b/packages/expo/src/app-version/appVersionSupport.ts @@ -119,7 +119,7 @@ export function applySupportedAppVersion(requestGeneration: number): void { return; } serverAnswerCount += 1; - serverStatus = supportedStatus(); + serverStatus = environmentStatus?.isSupported ? environmentStatus : supportedStatus(); publish(); } From 0b7e9ac53c3b40d8cd502e57b98af94571e6e49a Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 22 Oct 2026 13:42:34 -0400 Subject: [PATCH 8/9] refactor(expo): trim leftovers in the app version support code - The update screen, its modal and the restart hook read the status through useAppVersionSupport() instead of repeating its store subscription. - isRecord is shared between the version support files. - The version comparison returns whether the app meets its minimum, the one thing callers check. Co-Authored-By: Claude Opus 5.5 --- .../expo/src/app-version/appVersionRequestHooks.ts | 9 +++------ packages/expo/src/app-version/appVersionSupport.ts | 14 +++++++------- .../app-version/useClerkRestartWhenSupported.ts | 10 +++------- .../src/update-required/UpdateRequiredModal.tsx | 11 +++-------- .../src/update-required/UpdateRequiredView.tsx | 13 +++---------- 5 files changed, 19 insertions(+), 38 deletions(-) diff --git a/packages/expo/src/app-version/appVersionRequestHooks.ts b/packages/expo/src/app-version/appVersionRequestHooks.ts index dea2d30cf8b..dee3832ae71 100644 --- a/packages/expo/src/app-version/appVersionRequestHooks.ts +++ b/packages/expo/src/app-version/appVersionRequestHooks.ts @@ -7,6 +7,7 @@ import { getAppVersionSupportGeneration, getAppVersionSupportStatus, getServerAnswerCount, + isRecord, resetAppVersionSupport, } from './appVersionSupport'; import { setNativeAppIdentityHeaders } from './nativeAppIdentity'; @@ -20,8 +21,6 @@ type AppVersionRequest = { headers?: HeadersInit; }; -let foregroundSubscription: NativeEventSubscription | null = null; - type AppVersionResponse = { ok: boolean; status: number; @@ -29,6 +28,8 @@ type AppVersionResponse = { payload: unknown; }; +let foregroundSubscription: NativeEventSubscription | null = null; + export function createAppVersionRequestHooks() { resetAppVersionSupport(); foregroundSubscription?.remove(); @@ -76,7 +77,3 @@ function findUnsupportedAppVersionError(payload: unknown): { meta?: unknown } | function environmentJSON(payload: unknown): unknown { return isRecord(payload) && isRecord(payload.response) ? payload.response : payload; } - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} diff --git a/packages/expo/src/app-version/appVersionSupport.ts b/packages/expo/src/app-version/appVersionSupport.ts index 56410d79f79..6f18d7430f0 100644 --- a/packages/expo/src/app-version/appVersionSupport.ts +++ b/packages/expo/src/app-version/appVersionSupport.ts @@ -135,11 +135,11 @@ function resolveEnvironmentStatus(environment: unknown): AppVersionSupportStatus return supportedStatus(); } const minimumVersion = stringOrNull(policy.minimum_version); - const comparison = minimumVersion ? compareVersions(identity, minimumVersion) : null; + const meetsMinimum = minimumVersion ? isAtLeast(identity, minimumVersion) : null; return { - isSupported: comparison === null || comparison >= 0, + isSupported: meetsMinimum !== false, currentVersion: identity.version, - minimumVersion: comparison === null ? null : minimumVersion, + minimumVersion: meetsMinimum === null ? null : minimumVersion, updateUrl: httpUrlOrNull(policy.update_url), }; } @@ -158,7 +158,7 @@ function appIdentifier(entry: MinimumVersionJSON, platform: NativeAppPlatform): return stringOrNull(platform === 'ios' ? entry.bundle_id : entry.package_name); } -function compareVersions(identity: NativeAppIdentity, minimumVersion: string): number | null { +function isAtLeast(identity: NativeAppIdentity, minimumVersion: string): boolean | null { const current = parseVersion(identity.platform, identity.version); const required = parseVersion(identity.platform, minimumVersion); if (!current || !required) { @@ -167,10 +167,10 @@ function compareVersions(identity: NativeAppIdentity, minimumVersion: string): n 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; + return difference > 0; } } - return 0; + return true; } function parseVersion(platform: NativeAppPlatform, version: string): number[] | null { @@ -256,6 +256,6 @@ function stringOrNull(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null; } -function isRecord(value: unknown): value is Record { +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/useClerkRestartWhenSupported.ts b/packages/expo/src/app-version/useClerkRestartWhenSupported.ts index c1138c1ed44..cc28a3fd1f2 100644 --- a/packages/expo/src/app-version/useClerkRestartWhenSupported.ts +++ b/packages/expo/src/app-version/useClerkRestartWhenSupported.ts @@ -1,15 +1,11 @@ -import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { useEffect, useRef, useState } from 'react'; -import { getAppVersionSupportStatus, subscribeToAppVersionSupport } from './appVersionSupport'; +import { useAppVersionSupport } from '../hooks/useAppVersionSupport'; type LoadableClerk = { status?: string } | null; export function useClerkRestartWhenSupported(clerk: LoadableClerk, discardClerk: () => void): number { - const { isSupported } = useSyncExternalStore( - subscribeToAppVersionSupport, - getAppVersionSupportStatus, - getAppVersionSupportStatus, - ); + const { isSupported } = useAppVersionSupport(); const [restarts, setRestarts] = useState(0); const wasUnsupported = useRef(false); diff --git a/packages/expo/src/update-required/UpdateRequiredModal.tsx b/packages/expo/src/update-required/UpdateRequiredModal.tsx index f558dca9bdf..66e0a9b38fe 100644 --- a/packages/expo/src/update-required/UpdateRequiredModal.tsx +++ b/packages/expo/src/update-required/UpdateRequiredModal.tsx @@ -1,18 +1,13 @@ -import { useSyncExternalStore } from 'react'; import { Modal } from 'react-native'; -import { getAppVersionSupportStatus, subscribeToAppVersionSupport } from '../app-version/appVersionSupport'; +import { useAppVersionSupport } from '../hooks/useAppVersionSupport'; import { UpdateRequiredView } from './UpdateRequiredView'; export function UpdateRequiredModal(): JSX.Element { - const status = useSyncExternalStore( - subscribeToAppVersionSupport, - getAppVersionSupportStatus, - getAppVersionSupportStatus, - ); + const { isSupported } = useAppVersionSupport(); return ( {}} diff --git a/packages/expo/src/update-required/UpdateRequiredView.tsx b/packages/expo/src/update-required/UpdateRequiredView.tsx index 09a7452222a..1da94be4690 100644 --- a/packages/expo/src/update-required/UpdateRequiredView.tsx +++ b/packages/expo/src/update-required/UpdateRequiredView.tsx @@ -2,12 +2,9 @@ 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 { - getAppVersionSupportStatus, - getUpdateRequiredDisplay, - subscribeToAppVersionSupport, -} 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'; @@ -27,11 +24,7 @@ const MAX_ACTIONS_WIDTH = 320; * ``` */ export function UpdateRequiredView(): JSX.Element { - const status = useSyncExternalStore( - subscribeToAppVersionSupport, - getAppVersionSupportStatus, - getAppVersionSupportStatus, - ); + const status = useAppVersionSupport(); const display = useSyncExternalStore( subscribeToAppVersionSupport, getUpdateRequiredDisplay, From 1c199e8cc1d0b67c3d78d18299c416475688b898 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 22 Oct 2026 19:02:56 -0400 Subject: [PATCH 9/9] fix(expo): let the server's latest answer outrank the environment's minimum The environment can be served from the edge cache, so a stale copy used to override what FAPI had just answered: after a minimum or the allowlist changed, the update screen could reappear on each environment load, or disappear while FAPI kept rejecting requests. FAPI enforces the minimum, so its latest answer now decides once there is one, and the environment decides only before that. A supported answer still shows the environment's minimum and update URL when the environment agrees. The request hooks no longer track which answers arrived while the environment loaded. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/appVersionRequestHooks.test.ts | 6 +-- .../__tests__/appVersionSupport.test.ts | 38 ++++++++--------- .../src/app-version/appVersionRequestHooks.ts | 10 +---- .../expo/src/app-version/appVersionSupport.ts | 41 ++++++++----------- .../__tests__/UpdateRequiredView.test.tsx | 5 +-- 5 files changed, 42 insertions(+), 58 deletions(-) diff --git a/packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts b/packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts index 2267d8b5ccd..963b21435e7 100644 --- a/packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts +++ b/packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts @@ -114,15 +114,15 @@ describe('createAppVersionRequestHooks', () => { expect(status()).toMatchObject({ isSupported: false, minimumVersion: '2.0' }); }); - test('an environment that loads during a rejection keeps the block', async () => { + 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(environmentRequest); hooks.beforeRequest(clientRequest); - hooks.afterResponse(clientRequest, unsupportedAppVersion); + + hooks.beforeRequest(environmentRequest); hooks.afterResponse(environmentRequest, response({ payload: { native_app_settings: {} } })); expect(status().isSupported).toBe(false); diff --git a/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts b/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts index d99bcf1f448..6ca5867f515 100644 --- a/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts +++ b/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts @@ -45,10 +45,7 @@ describe('appVersionSupport', () => { }); const fetchEnvironment = (store: Awaited>, json: unknown) => { - store.applyFetchedEnvironment(json, { - generation: store.getAppVersionSupportGeneration(), - serverAnswersAtRequest: store.getServerAnswerCount(), - }); + store.applyFetchedEnvironment(json, store.getAppVersionSupportGeneration()); }; test('reports a supported app before anything is known', async () => { @@ -170,36 +167,37 @@ describe('appVersionSupport', () => { }); }); - test('an environment requested after a rejection lifts it', async () => { + test('an environment does not lift a rejection', async () => { const store = await loadStore(); - store.applyUnsupportedAppVersion(null, store.getAppVersionSupportGeneration()); + store.applyUnsupportedAppVersion({ minimum_version: '2.0' }, store.getAppVersionSupportGeneration()); fetchEnvironment(store, environment()); - expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + expect(store.getAppVersionSupportStatus()).toMatchObject({ isSupported: false, minimumVersion: '2.0' }); }); - test('an environment in flight during a rejection keeps the block', async () => { + test('an environment does not block an app a response marked supported', async () => { const store = await loadStore(); - const serverAnswersAtRequest = store.getServerAnswerCount(); - const generation = store.getAppVersionSupportGeneration(); + store.applySupportedAppVersion(store.getAppVersionSupportGeneration()); - store.applyUnsupportedAppVersion({ minimum_version: '2.0' }, generation); - store.applyFetchedEnvironment(environment(), { generation, serverAnswersAtRequest }); + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); - expect(store.getAppVersionSupportStatus().isSupported).toBe(false); + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: true, + currentVersion: '1.9.9', + minimumVersion: null, + updateUrl: null, + }); }); - test('a supported answer during the fetch does not hide the environment block', async () => { + 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(); - const serverAnswersAtRequest = store.getServerAnswerCount(); - const generation = store.getAppVersionSupportGeneration(); + store.applySupportedAppVersion(store.getAppVersionSupportGeneration()); - store.applySupportedAppVersion(generation); - store.applyFetchedEnvironment(environment({ ios: [iosPolicy('2.0')] }), { generation, serverAnswersAtRequest }); + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); - expect(store.getAppVersionSupportStatus().isSupported).toBe(false); - expect(store.getAppVersionSupportStatus().minimumVersion).toBe('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 () => { diff --git a/packages/expo/src/app-version/appVersionRequestHooks.ts b/packages/expo/src/app-version/appVersionRequestHooks.ts index dee3832ae71..543c01119d8 100644 --- a/packages/expo/src/app-version/appVersionRequestHooks.ts +++ b/packages/expo/src/app-version/appVersionRequestHooks.ts @@ -6,7 +6,6 @@ import { applyUnsupportedAppVersion, getAppVersionSupportGeneration, getAppVersionSupportStatus, - getServerAnswerCount, isRecord, resetAppVersionSupport, } from './appVersionSupport'; @@ -35,7 +34,6 @@ export function createAppVersionRequestHooks() { foregroundSubscription?.remove(); foregroundSubscription = null; const generation = getAppVersionSupportGeneration(); - const serverAnswersAtEnvironmentRequest = new WeakMap(); return { refreshOnForegroundWhileUnsupported(refresh: () => Promise) { @@ -47,9 +45,6 @@ export function createAppVersionRequestHooks() { }, beforeRequest(requestInit: AppVersionRequest) { setNativeAppIdentityHeaders(requestInit.headers as Headers); - if (requestInit.path === ENVIRONMENT_PATH) { - serverAnswersAtEnvironmentRequest.set(requestInit, getServerAnswerCount()); - } }, afterResponse(requestInit: AppVersionRequest, response: AppVersionResponse) { const unsupportedAppVersionError = @@ -61,9 +56,8 @@ export function createAppVersionRequestHooks() { if (response.headers.get(APP_VERSION_STATUS_HEADER)?.toLowerCase() === 'supported') { applySupportedAppVersion(generation); } - const serverAnswersAtRequest = serverAnswersAtEnvironmentRequest.get(requestInit); - if (response.ok && serverAnswersAtRequest !== undefined) { - applyFetchedEnvironment(environmentJSON(response.payload), { generation, serverAnswersAtRequest }); + if (response.ok && requestInit.path === ENVIRONMENT_PATH) { + applyFetchedEnvironment(environmentJSON(response.payload), generation); } }, }; diff --git a/packages/expo/src/app-version/appVersionSupport.ts b/packages/expo/src/app-version/appVersionSupport.ts index 6f18d7430f0..fbf12d5a7dc 100644 --- a/packages/expo/src/app-version/appVersionSupport.ts +++ b/packages/expo/src/app-version/appVersionSupport.ts @@ -43,8 +43,7 @@ const ANDROID_VERSION_CODE = /^[1-9]\d*$/; const listeners = new Set<() => void>(); let environmentStatus: AppVersionSupportStatus | null = null; -let serverStatus: AppVersionSupportStatus | null = null; -let serverAnswerCount = 0; +let serverAnswer: { isSupported: true } | { isSupported: false; status: AppVersionSupportStatus } | null = null; let generation = 0; let status = supportedStatus(); let display = EMPTY_DISPLAY; @@ -67,29 +66,18 @@ export function getAppVersionSupportGeneration(): number { return generation; } -export function getServerAnswerCount(): number { - return serverAnswerCount; -} - export function resetAppVersionSupport(): void { generation += 1; environmentStatus = null; - serverStatus = null; + serverAnswer = null; display = EMPTY_DISPLAY; publish({ displayChanged: true }); } -export function applyFetchedEnvironment( - environment: unknown, - { generation: requestGeneration, serverAnswersAtRequest }: { generation: number; serverAnswersAtRequest: number }, -): void { +export function applyFetchedEnvironment(environment: unknown, requestGeneration: number): void { if (requestGeneration !== generation) { return; } - const answeredDuringFetch = serverAnswersAtRequest !== serverAnswerCount; - if (!answeredDuringFetch || serverStatus?.isSupported !== false) { - serverStatus = null; - } environmentStatus = resolveEnvironmentStatus(environment); const nextDisplay = resolveDisplay(environment); const displayChanged = !sameDisplay(nextDisplay, display); @@ -103,13 +91,15 @@ export function applyUnsupportedAppVersion(meta: unknown, requestGeneration: num if (requestGeneration !== generation) { return; } - serverAnswerCount += 1; const fields = isRecord(meta) ? meta : {}; - serverStatus = { + serverAnswer = { 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, + 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(); } @@ -118,8 +108,7 @@ export function applySupportedAppVersion(requestGeneration: number): void { if (requestGeneration !== generation) { return; } - serverAnswerCount += 1; - serverStatus = environmentStatus?.isSupported ? environmentStatus : supportedStatus(); + serverAnswer = { isSupported: true }; publish(); } @@ -215,7 +204,13 @@ function supportedStatus(): AppVersionSupportStatus { } function publish({ displayChanged = false }: { displayChanged?: boolean } = {}): void { - const next = serverStatus ?? environmentStatus ?? supportedStatus(); + const next = !serverAnswer + ? (environmentStatus ?? supportedStatus()) + : !serverAnswer.isSupported + ? serverAnswer.status + : environmentStatus?.isSupported + ? environmentStatus + : supportedStatus(); const statusChanged = !sameStatus(next, status); if (statusChanged) { status = next; diff --git a/packages/expo/src/update-required/__tests__/UpdateRequiredView.test.tsx b/packages/expo/src/update-required/__tests__/UpdateRequiredView.test.tsx index 4e57366e3d3..b84e2589d87 100644 --- a/packages/expo/src/update-required/__tests__/UpdateRequiredView.test.tsx +++ b/packages/expo/src/update-required/__tests__/UpdateRequiredView.test.tsx @@ -78,10 +78,7 @@ describe('UpdateRequiredView', () => { options: { updateUrl: string; supportEmail?: string }, ) => { act(() => { - store.applyFetchedEnvironment(environment(options), { - generation: store.getAppVersionSupportGeneration(), - serverAnswersAtRequest: store.getServerAnswerCount(), - }); + store.applyFetchedEnvironment(environment(options), store.getAppVersionSupportGeneration()); }); };