From 6da3129949c1fb30488d78298a88a749ea29081b Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 21 Oct 2026 15:17:15 -0400 Subject: [PATCH 01/16] 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 02/16] 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 03/16] 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 04/16] 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 05/16] 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 06/16] 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 07/16] 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 08/16] 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 09/16] 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()); }); }; From 43eb2fd7f5438fae344454712ae1d0602e7a03a7 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 23 Oct 2026 12:04:31 -0400 Subject: [PATCH 10/16] fix(expo): drop the account-security reason from the update screen "To keep your account secure" implied older versions are insecure, when a customer may retire a version for any reason. The subtitle now reads "This version of is no longer supported. Please update to continue.", matching the native SDKs. Co-Authored-By: Claude Opus 5.5 --- packages/expo/src/update-required/UpdateRequiredView.tsx | 2 +- .../update-required/__tests__/UpdateRequiredView.test.tsx | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/expo/src/update-required/UpdateRequiredView.tsx b/packages/expo/src/update-required/UpdateRequiredView.tsx index 1da94be4690..abeacb2815d 100644 --- a/packages/expo/src/update-required/UpdateRequiredView.tsx +++ b/packages/expo/src/update-required/UpdateRequiredView.tsx @@ -84,7 +84,7 @@ export function UpdateRequiredContent({ Update required - {`This version of ${appName} is no longer supported. To keep your account secure, please update to continue.`} + {`This version of ${appName} is no longer supported. Please update to continue.`} { 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('This version of Acme is no longer supported. Please update to continue.')).toBeDefined(); expect(screen.getByText('Your version')).toBeDefined(); expect(screen.getByText('1.9.9')).toBeDefined(); expect(screen.getByText('2.0+')).toBeDefined(); From c8c932d73e84b77a557e4d8e301c514d602d9929 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 23 Oct 2026 12:59:04 -0400 Subject: [PATCH 11/16] fix(expo): turn off the native SDKs' update screens shows its own UpdateRequiredView on both platforms, so the native SDKs' screens would cover it and ignore updateRequiredViewEnabled. Pins clerk-ios to clerk/clerk-ios#742 until it's released, and needs the clerk-android release with clerk/clerk-android#1160. Co-Authored-By: Claude Opus 5.5 --- .changeset/expo-native-components-update-screen.md | 5 +++++ .../src/main/java/expo/modules/clerk/ClerkExpoModule.kt | 1 + packages/expo-native-components/ios/ClerkExpo.podspec | 4 ++-- .../expo-native-components/ios/ClerkNativeBridge.swift | 9 +++++++-- 4 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 .changeset/expo-native-components-update-screen.md diff --git a/.changeset/expo-native-components-update-screen.md b/.changeset/expo-native-components-update-screen.md new file mode 100644 index 00000000000..a053dc7cea3 --- /dev/null +++ b/.changeset/expo-native-components-update-screen.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo-native-components': patch +--- + +Turn off the native iOS and Android SDKs' own update-required screens, so an app whose version is older than its minimum sees only the `` that `` shows, and `updateRequiredViewEnabled={false}` on `` turns it off on every platform. diff --git a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index 27055ffdaf4..328102d7935 100644 --- a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -112,6 +112,7 @@ class ClerkExpoModule : Module() { // JS owns client state. The native foreground refresh races SSO completion and mints duplicate clients (#9217). return ClerkConfigurationOptions(proxyUrl = proxyUrl) .withForegroundRefreshDisabled() + .withUpdateRequiredViewDisabled() .withCustomHeaders(customHeaders) } diff --git a/packages/expo-native-components/ios/ClerkExpo.podspec b/packages/expo-native-components/ios/ClerkExpo.podspec index f2229b9f5bc..80cd4950961 100644 --- a/packages/expo-native-components/ios/ClerkExpo.podspec +++ b/packages/expo-native-components/ios/ClerkExpo.podspec @@ -18,7 +18,7 @@ else end clerk_ios_repo = 'https://github.com/clerk/clerk-ios.git' -clerk_ios_version = '1.6.1' +clerk_ios_revision = '38ed765bbdbcf6342b39d2e4ad853b0bdf7898c3' Pod::Spec.new do |s| s.name = 'ClerkExpo' @@ -44,7 +44,7 @@ Pod::Spec.new do |s| spm_dependency( s, url: clerk_ios_repo, - requirement: { :kind => 'exactVersion', :version => clerk_ios_version }, + requirement: { :kind => 'revision', :revision => clerk_ios_revision }, products: ['ClerkKit', 'ClerkKitUI'] ) else diff --git a/packages/expo-native-components/ios/ClerkNativeBridge.swift b/packages/expo-native-components/ios/ClerkNativeBridge.swift index 5616f82ae37..438b74a705e 100644 --- a/packages/expo-native-components/ios/ClerkNativeBridge.swift +++ b/packages/expo-native-components/ios/ClerkNativeBridge.swift @@ -672,9 +672,14 @@ final class ClerkNativeBridge { private static func makeClerkOptions(proxyUrl: String?) -> Clerk.Options { let middleware = Clerk.Options.MiddlewareConfig(request: [ClerkExpoHeaderMiddleware()]) guard let service = keychainService else { - return .init(proxyUrl: proxyUrl, middleware: middleware) + return .init(proxyUrl: proxyUrl, middleware: middleware, updateRequiredViewEnabled: false) } - return .init(keychainConfig: .init(service: service), proxyUrl: proxyUrl, middleware: middleware) + return .init( + keychainConfig: .init(service: service), + proxyUrl: proxyUrl, + middleware: middleware, + updateRequiredViewEnabled: false + ) } @MainActor From 0ea9bb0187525a20fe7f2835fd2aad8e4e61a490 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 23 Oct 2026 13:13:12 -0400 Subject: [PATCH 12/16] refactor(expo): name the app version request hooks' return type Co-Authored-By: Claude Opus 5.5 --- packages/expo/src/app-version/appVersionRequestHooks.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/expo/src/app-version/appVersionRequestHooks.ts b/packages/expo/src/app-version/appVersionRequestHooks.ts index 543c01119d8..96f636c9a73 100644 --- a/packages/expo/src/app-version/appVersionRequestHooks.ts +++ b/packages/expo/src/app-version/appVersionRequestHooks.ts @@ -27,9 +27,15 @@ type AppVersionResponse = { payload: unknown; }; +type AppVersionRequestHooks = { + refreshOnForegroundWhileUnsupported: (refresh: () => Promise) => void; + beforeRequest: (requestInit: AppVersionRequest) => void; + afterResponse: (requestInit: AppVersionRequest, response: AppVersionResponse) => void; +}; + let foregroundSubscription: NativeEventSubscription | null = null; -export function createAppVersionRequestHooks() { +export function createAppVersionRequestHooks(): AppVersionRequestHooks { resetAppVersionSupport(); foregroundSubscription?.remove(); foregroundSubscription = null; From 61c5660e42d3e55da95e930691cba265fd26bc15 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 23 Oct 2026 15:10:09 -0400 Subject: [PATCH 13/16] fix(expo): notify app version subscribers after a reset, not during it creates its Clerk instance while it renders, and that resets the app version store. Notifying subscribers synchronously updated other components mid-render once a minimum had been published, so React warned on every restart after a block lifted. The store still updates immediately, so components that render afterwards read the new status; subscribers are told on a microtask. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/appVersionSupport.test.ts | 34 +++++++++++++++++++ .../expo/src/app-version/appVersionSupport.ts | 17 ++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts b/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts index 6ca5867f515..3399bdcc347 100644 --- a/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts +++ b/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts @@ -1,3 +1,5 @@ +import { act, render } from '@testing-library/react'; +import { createElement, useRef } from 'react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { NativeAppIdentity } from '../nativeAppIdentity'; @@ -239,6 +241,38 @@ describe('appVersionSupport', () => { expect(store.getAppVersionSupportStatus().isSupported).toBe(true); }); + test('a reset during render updates subscribers after the render, not during it', async () => { + const store = await loadStore(); + const { useAppVersionSupport } = await import('../../hooks/useAppVersionSupport'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const seen: (string | null)[] = []; + const Subscriber = () => { + seen.push(useAppVersionSupport().minimumVersion); + return null; + }; + const Provider = ({ instance }: { instance: number }) => { + const resetFor = useRef(0); + if (resetFor.current !== instance) { + resetFor.current = instance; + store.resetAppVersionSupport(); + } + return createElement(Subscriber); + }; + const view = render(createElement(Provider, { instance: 0 })); + act(() => fetchEnvironment(store, environment({ ios: [iosPolicy('1.0')] }))); + + await act(async () => { + view.rerender(createElement(Provider, { instance: 1 })); + await Promise.resolve(); + }); + + expect(consoleError.mock.calls.some(([message]) => String(message).includes('Cannot update a component'))).toBe( + false, + ); + expect(store.getAppVersionSupportStatus().minimumVersion).toBeNull(); + expect(seen.at(-1)).toBeNull(); + }); + test('notifies subscribers only when the status changes', async () => { const store = await loadStore(); const listener = vi.fn(); diff --git a/packages/expo/src/app-version/appVersionSupport.ts b/packages/expo/src/app-version/appVersionSupport.ts index fbf12d5a7dc..5b39e1a3c84 100644 --- a/packages/expo/src/app-version/appVersionSupport.ts +++ b/packages/expo/src/app-version/appVersionSupport.ts @@ -71,7 +71,7 @@ export function resetAppVersionSupport(): void { environmentStatus = null; serverAnswer = null; display = EMPTY_DISPLAY; - publish({ displayChanged: true }); + publish({ displayChanged: true, notifyLater: true }); } export function applyFetchedEnvironment(environment: unknown, requestGeneration: number): void { @@ -203,7 +203,10 @@ function supportedStatus(): AppVersionSupportStatus { }; } -function publish({ displayChanged = false }: { displayChanged?: boolean } = {}): void { +function publish({ + displayChanged = false, + notifyLater = false, +}: { displayChanged?: boolean; notifyLater?: boolean } = {}): void { const next = !serverAnswer ? (environmentStatus ?? supportedStatus()) : !serverAnswer.isSupported @@ -216,10 +219,18 @@ function publish({ displayChanged = false }: { displayChanged?: boolean } = {}): status = next; } if (statusChanged || displayChanged) { - listeners.forEach(listener => listener()); + if (notifyLater) { + queueMicrotask(notifyListeners); + } else { + notifyListeners(); + } } } +function notifyListeners(): void { + listeners.forEach(listener => listener()); +} + function resolveDisplay(environment: unknown): UpdateRequiredDisplay { const displayConfig = isRecord(environment) ? environment.display_config : undefined; if (!isRecord(displayConfig)) { From 7c125e280650d93dd1206d8d2a74776a432ef6ce Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 23 Oct 2026 15:10:10 -0400 Subject: [PATCH 14/16] fix(expo): keep the native update screen unless @clerk/expo replaces it The bridge turned the native SDKs' update screens off unconditionally. An app on an older @clerk/expo, which has no update screen, or without expo-application, which @clerk/expo needs to identify the app, then showed none while native requests were rejected. The native screen now stays on by default. @clerk/expo turns it off through a new optional setUpdateRequiredViewEnabled bridge method, called before configureNative, when it shows its own screen or the app sets updateRequiredViewEnabled={false}. configureNative keeps its signature, and @clerk/expo skips the call on native components without the method. The setting applies when native Clerk is configured. Co-Authored-By: Claude Opus 5.5 --- .../expo-native-components-update-screen.md | 2 +- .../expo/modules/clerk/ClerkExpoModule.kt | 10 +++-- .../ios/ClerkExpoModule.swift | 7 ++++ .../ios/ClerkNativeBridge.swift | 10 ++++- packages/expo/src/provider/ClerkProvider.tsx | 15 ++++++- .../__tests__/nativeClientSync.test.ts | 41 +++++++++++++++++++ .../expo/src/provider/nativeClientSync.ts | 7 +++- .../src/specs/NativeClerkModule.android.ts | 8 +++- packages/expo/src/specs/NativeClerkModule.ts | 9 +++- .../expo/src/specs/NativeClerkModule.types.ts | 4 ++ packages/expo/src/utils/native-module.ts | 9 +++- 11 files changed, 107 insertions(+), 15 deletions(-) diff --git a/.changeset/expo-native-components-update-screen.md b/.changeset/expo-native-components-update-screen.md index a053dc7cea3..fa36f8798ad 100644 --- a/.changeset/expo-native-components-update-screen.md +++ b/.changeset/expo-native-components-update-screen.md @@ -2,4 +2,4 @@ '@clerk/expo-native-components': patch --- -Turn off the native iOS and Android SDKs' own update-required screens, so an app whose version is older than its minimum sees only the `` that `` shows, and `updateRequiredViewEnabled={false}` on `` turns it off on every platform. +Let `@clerk/expo` turn off the native iOS and Android SDKs' own update-required screens. `` turns them off when it shows its own ``, or when you set `updateRequiredViewEnabled={false}`, so an app whose version is older than its minimum sees one screen. Older versions of `@clerk/expo`, and apps without `expo-application`, which `@clerk/expo` needs to identify the app, keep the native screen. diff --git a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index 328102d7935..4e19c06daaf 100644 --- a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -53,6 +53,7 @@ class ClerkExpoModule : Module() { emit = { sendEvent(NATIVE_CLIENT_INVALIDATED_EVENT, emptyMap()) } ) private var configuredProxyUrl: String? = null + @Volatile private var updateRequiredViewEnabled = true private data class AuthFlowStateSnapshot( val isLoaded: Boolean, @@ -80,6 +81,10 @@ class ClerkExpoModule : Module() { configureNative(pubKey, seedDeviceToken, proxyUrl, promise) } + AsyncFunction("setUpdateRequiredViewEnabled") { enabled: Boolean -> + updateRequiredViewEnabled = enabled + } + AsyncFunction("getDeviceToken") { promise: Promise -> getDeviceToken(promise) } @@ -110,9 +115,8 @@ class ClerkExpoModule : Module() { } // JS owns client state. The native foreground refresh races SSO completion and mints duplicate clients (#9217). - return ClerkConfigurationOptions(proxyUrl = proxyUrl) - .withForegroundRefreshDisabled() - .withUpdateRequiredViewDisabled() + val options = ClerkConfigurationOptions(proxyUrl = proxyUrl).withForegroundRefreshDisabled() + return (if (updateRequiredViewEnabled) options else options.withUpdateRequiredViewDisabled()) .withCustomHeaders(customHeaders) } diff --git a/packages/expo-native-components/ios/ClerkExpoModule.swift b/packages/expo-native-components/ios/ClerkExpoModule.swift index 55d54335473..1a7edaef530 100644 --- a/packages/expo-native-components/ios/ClerkExpoModule.swift +++ b/packages/expo-native-components/ios/ClerkExpoModule.swift @@ -52,6 +52,13 @@ public class ClerkExpoModule: Module { } } + AsyncFunction("setUpdateRequiredViewEnabled") { (enabled: Bool, promise: Promise) in + Task { @MainActor in + ClerkNativeBridge.setUpdateRequiredViewEnabled(enabled) + promise.resolve() + } + } + AsyncFunction("getDeviceToken") { (promise: Promise) in Task { @MainActor in promise.resolve(ClerkNativeBridge.shared.getDeviceToken()) diff --git a/packages/expo-native-components/ios/ClerkNativeBridge.swift b/packages/expo-native-components/ios/ClerkNativeBridge.swift index 438b74a705e..226f3f78fd9 100644 --- a/packages/expo-native-components/ios/ClerkNativeBridge.swift +++ b/packages/expo-native-components/ios/ClerkNativeBridge.swift @@ -420,6 +420,7 @@ final class ClerkNativeBridge { private static var clerkConfigured = false private static var configuredPublishableKey: String? private static var configuredProxyUrl: String? + private static var updateRequiredViewEnabled = true /// Parsed light and dark themes from Info.plist "ClerkTheme" dictionary. var lightTheme: ClerkTheme? @@ -672,16 +673,21 @@ final class ClerkNativeBridge { private static func makeClerkOptions(proxyUrl: String?) -> Clerk.Options { let middleware = Clerk.Options.MiddlewareConfig(request: [ClerkExpoHeaderMiddleware()]) guard let service = keychainService else { - return .init(proxyUrl: proxyUrl, middleware: middleware, updateRequiredViewEnabled: false) + return .init(proxyUrl: proxyUrl, middleware: middleware, updateRequiredViewEnabled: updateRequiredViewEnabled) } return .init( keychainConfig: .init(service: service), proxyUrl: proxyUrl, middleware: middleware, - updateRequiredViewEnabled: false + updateRequiredViewEnabled: updateRequiredViewEnabled ) } + @MainActor + static func setUpdateRequiredViewEnabled(_ enabled: Bool) { + updateRequiredViewEnabled = enabled + } + @MainActor func getAuthFlowState() -> [String: Any] { Self.authFlowStatePayload(Self.authFlowStateSnapshot()) diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index e4ed6847df3..f04ca3313c0 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -4,6 +4,7 @@ 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 { getNativeAppIdentity } from '../app-version/nativeAppIdentity'; import { useClerkRestartWhenSupported } from '../app-version/useClerkRestartWhenSupported'; import type { TokenCache } from '../cache/types'; import { UpdateRequiredModal } from '../update-required/UpdateRequiredModal'; @@ -84,9 +85,19 @@ export function ClerkProvider(props: ClerkProviderProps (nativeModule ? createNativeClientSync(nativeModule, pk, nativeProxyUrl, () => tokenCacheRef.current) : null), - [nativeModule, pk, nativeProxyUrl], + () => + nativeModule + ? createNativeClientSync( + nativeModule, + pk, + nativeProxyUrl, + () => tokenCacheRef.current, + nativeUpdateRequiredViewEnabled, + ) + : null, + [nativeModule, pk, nativeProxyUrl, nativeUpdateRequiredViewEnabled], ); const clerkInstance = isNative() diff --git a/packages/expo/src/provider/__tests__/nativeClientSync.test.ts b/packages/expo/src/provider/__tests__/nativeClientSync.test.ts index e2c6d2837d8..0ed648c6523 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSync.test.ts +++ b/packages/expo/src/provider/__tests__/nativeClientSync.test.ts @@ -67,6 +67,47 @@ describe('createNativeClientSync', () => { expect(native.configureNative).toHaveBeenCalledWith('pk_test_123', 'seed-token', 'https://example.com/__clerk'); }); + test('tells native whether to show its update screen before configuring it', async () => { + const createNativeClientSync = await loadEngine(); + const native = { ...nativeModule(), setUpdateRequiredViewEnabled: vi.fn().mockResolvedValue(undefined) }; + const cache = userTokenCache(vi.fn().mockResolvedValue(null)); + + await createNativeClientSync(native, 'pk_test_123', null, () => cache, false).tokenCache.getToken( + CLERK_CLIENT_JWT_KEY, + ); + + expect(native.setUpdateRequiredViewEnabled).toHaveBeenCalledWith(false); + expect(vi.mocked(native.setUpdateRequiredViewEnabled).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(native.configureNative).mock.invocationCallOrder[0], + ); + }); + + test('leaves the native update screen on unless told otherwise', async () => { + const createNativeClientSync = await loadEngine(); + const native = { ...nativeModule(), setUpdateRequiredViewEnabled: vi.fn().mockResolvedValue(undefined) }; + const cache = userTokenCache(vi.fn().mockResolvedValue(null)); + + await createNativeClientSync(native, 'pk_test_123', null, () => cache).tokenCache.getToken(CLERK_CLIENT_JWT_KEY); + + expect(native.setUpdateRequiredViewEnabled).toHaveBeenCalledWith(true); + }); + + test('still configures native when setting its update screen fails', async () => { + const createNativeClientSync = await loadEngine(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const native = { + ...nativeModule(), + setUpdateRequiredViewEnabled: vi.fn().mockRejectedValue(new Error('not available')), + }; + const cache = userTokenCache(vi.fn().mockResolvedValue(null)); + + await createNativeClientSync(native, 'pk_test_123', null, () => cache, false).tokenCache.getToken( + CLERK_CLIENT_JWT_KEY, + ); + + expect(native.configureNative).toHaveBeenCalledWith('pk_test_123', null, null); + }); + test('configures native again when the proxy URL changes', async () => { const createNativeClientSync = await loadEngine(); const native = nativeModule(); diff --git a/packages/expo/src/provider/nativeClientSync.ts b/packages/expo/src/provider/nativeClientSync.ts index 7e10de6836a..9e870e98e94 100644 --- a/packages/expo/src/provider/nativeClientSync.ts +++ b/packages/expo/src/provider/nativeClientSync.ts @@ -118,12 +118,16 @@ function configureOnce( publishableKey: string, proxyUrl: string | null, tokenCache: TokenCache, + updateRequiredViewEnabled: boolean, ) { if (configuration?.publishableKey !== publishableKey || configuration.proxyUrl !== proxyUrl) { let configured = false; let timedOut = false; const configuring = (async () => { const seed = await readSeedDeviceToken(tokenCache); + await native + .setUpdateRequiredViewEnabled?.(updateRequiredViewEnabled) + .catch(error => warn('Failed to set whether the native SDK shows its update screen.', error)); await native .configureNative(publishableKey, seed, proxyUrl) .catch(() => native.configureNative(publishableKey, seed, proxyUrl)); @@ -165,9 +169,10 @@ export function createNativeClientSync( publishableKey: string, proxyUrl: string | null, getUserTokenCache: () => TokenCache | undefined, + nativeUpdateRequiredViewEnabled = true, ) { const fallback = () => getUserTokenCache() ?? MemoryTokenCache; - const ready = () => configureOnce(native, publishableKey, proxyUrl, fallback()); + const ready = () => configureOnce(native, publishableKey, proxyUrl, fallback(), nativeUpdateRequiredViewEnabled); let mirroredToken: string | null = null; let lastFingerprint: string | undefined; let refreshFailure: { error: unknown } | null = null; diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index 2447e7caf0d..931e6fb03c7 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -1,8 +1,12 @@ import { requireOptionalNativeModule } from 'expo'; -import type { NativeAuthFlowModule, NativeClientSyncModule } from './NativeClerkModule.types'; +import type { + NativeAuthFlowModule, + NativeClientSyncModule, + NativeUpdateRequiredViewModule, +} from './NativeClerkModule.types'; -interface Spec extends NativeAuthFlowModule, Partial { +interface Spec extends NativeAuthFlowModule, Partial, Partial { // Exposed by Expo Modules EventEmitter for the internal `clerkNativeClientInvalidated` event. // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index b99f38e0460..6d71d7f317a 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -1,8 +1,13 @@ import { requireOptionalNativeModule } from 'expo'; -import type { NativeAuthFlowModule, NativeClientSyncModule } from './NativeClerkModule.types'; +import type { + NativeAuthFlowModule, + NativeClientSyncModule, + NativeUpdateRequiredViewModule, +} from './NativeClerkModule.types'; -export interface Spec extends NativeAuthFlowModule, Partial { +export interface Spec + extends NativeAuthFlowModule, Partial, Partial { // Exposed by Expo Modules EventEmitter for the internal `clerkNativeClientInvalidated` event. // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; diff --git a/packages/expo/src/specs/NativeClerkModule.types.ts b/packages/expo/src/specs/NativeClerkModule.types.ts index 9165f335198..8504ad003d4 100644 --- a/packages/expo/src/specs/NativeClerkModule.types.ts +++ b/packages/expo/src/specs/NativeClerkModule.types.ts @@ -7,6 +7,10 @@ export type NativeAuthFlowModule = { getAuthFlowState(): Promise; }; +export type NativeUpdateRequiredViewModule = { + setUpdateRequiredViewEnabled(enabled: boolean): Promise; +}; + /** * Native storage owns the device token. JS reads it before each FAPI request, writes rotated tokens * back with compare-and-set, and refetches its own client on `clerkNativeClientInvalidated`. diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts index dd0d06524da..70199b41f0d 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -1,14 +1,19 @@ import { Platform } from 'react-native'; import NativeClerkModule from '../specs/NativeClerkModule'; -import type { NativeAuthFlowModule, NativeClientSyncModule } from '../specs/NativeClerkModule.types'; +import type { + NativeAuthFlowModule, + NativeClientSyncModule, + NativeUpdateRequiredViewModule, +} from '../specs/NativeClerkModule.types'; export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; export type ClerkExpoNativeModule = { addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; } & NativeClientSyncModule & - Partial; + Partial & + Partial; function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule { if (!module || typeof module !== 'object') { From c1be59f050fe476a1ad92ee0a07006fe188b23a0 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 23 Oct 2026 15:24:52 -0400 Subject: [PATCH 15/16] refactor(expo): read the native update screen setting when native configures Passing the computed setting as a useMemo dependency recreated the native client sync object whenever the prop changed, dropping its state for nothing: the native SDKs only read the option when they're configured. Read it through a ref instead, like the token cache, when configureNative runs. Co-Authored-By: Claude Opus 5.5 --- packages/expo/src/provider/ClerkProvider.tsx | 7 ++++--- .../__tests__/nativeClientSync.test.ts | 20 +++++++++++++------ .../expo/src/provider/nativeClientSync.ts | 4 ++-- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index f04ca3313c0..ac6e210391f 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -85,7 +85,8 @@ export function ClerkProvider(props: ClerkProviderProps nativeModule @@ -94,10 +95,10 @@ export function ClerkProvider(props: ClerkProviderProps tokenCacheRef.current, - nativeUpdateRequiredViewEnabled, + () => nativeUpdateRequiredViewEnabledRef.current, ) : null, - [nativeModule, pk, nativeProxyUrl, nativeUpdateRequiredViewEnabled], + [nativeModule, pk, nativeProxyUrl], ); const clerkInstance = isNative() diff --git a/packages/expo/src/provider/__tests__/nativeClientSync.test.ts b/packages/expo/src/provider/__tests__/nativeClientSync.test.ts index 0ed648c6523..1b6e6fbd37a 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSync.test.ts +++ b/packages/expo/src/provider/__tests__/nativeClientSync.test.ts @@ -72,9 +72,13 @@ describe('createNativeClientSync', () => { const native = { ...nativeModule(), setUpdateRequiredViewEnabled: vi.fn().mockResolvedValue(undefined) }; const cache = userTokenCache(vi.fn().mockResolvedValue(null)); - await createNativeClientSync(native, 'pk_test_123', null, () => cache, false).tokenCache.getToken( - CLERK_CLIENT_JWT_KEY, - ); + await createNativeClientSync( + native, + 'pk_test_123', + null, + () => cache, + () => false, + ).tokenCache.getToken(CLERK_CLIENT_JWT_KEY); expect(native.setUpdateRequiredViewEnabled).toHaveBeenCalledWith(false); expect(vi.mocked(native.setUpdateRequiredViewEnabled).mock.invocationCallOrder[0]).toBeLessThan( @@ -101,9 +105,13 @@ describe('createNativeClientSync', () => { }; const cache = userTokenCache(vi.fn().mockResolvedValue(null)); - await createNativeClientSync(native, 'pk_test_123', null, () => cache, false).tokenCache.getToken( - CLERK_CLIENT_JWT_KEY, - ); + await createNativeClientSync( + native, + 'pk_test_123', + null, + () => cache, + () => false, + ).tokenCache.getToken(CLERK_CLIENT_JWT_KEY); expect(native.configureNative).toHaveBeenCalledWith('pk_test_123', null, null); }); diff --git a/packages/expo/src/provider/nativeClientSync.ts b/packages/expo/src/provider/nativeClientSync.ts index 9e870e98e94..9d58ad43ed3 100644 --- a/packages/expo/src/provider/nativeClientSync.ts +++ b/packages/expo/src/provider/nativeClientSync.ts @@ -169,10 +169,10 @@ export function createNativeClientSync( publishableKey: string, proxyUrl: string | null, getUserTokenCache: () => TokenCache | undefined, - nativeUpdateRequiredViewEnabled = true, + getNativeUpdateRequiredViewEnabled: () => boolean = () => true, ) { const fallback = () => getUserTokenCache() ?? MemoryTokenCache; - const ready = () => configureOnce(native, publishableKey, proxyUrl, fallback(), nativeUpdateRequiredViewEnabled); + const ready = () => configureOnce(native, publishableKey, proxyUrl, fallback(), getNativeUpdateRequiredViewEnabled()); let mirroredToken: string | null = null; let lastFingerprint: string | undefined; let refreshFailure: { error: unknown } | null = null; From 9b256bd877719c31cce25b757d4fb307b6939a99 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 23 Oct 2026 16:21:27 -0400 Subject: [PATCH 16/16] fix(expo): keep UpdateRequiredView internal ClerkProvider shows the update screen itself, and apps that want their own UI build it from useAppVersionSupport(). Co-Authored-By: Claude Opus 5.5 --- .changeset/expo-native-app-minimum-versions.md | 4 ++-- .changeset/expo-native-components-update-screen.md | 2 +- packages/expo/src/index.ts | 1 - packages/expo/src/provider/ClerkProvider.tsx | 2 +- .../expo/src/update-required/UpdateRequiredView.tsx | 13 ------------- packages/expo/src/update-required/index.ts | 1 - 6 files changed, 4 insertions(+), 19 deletions(-) delete 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 index a2669bc9a75..6d71b7d21d8 100644 --- a/.changeset/expo-native-app-minimum-versions.md +++ b/.changeset/expo-native-app-minimum-versions.md @@ -2,8 +2,8 @@ '@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. +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 an update 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. +To show your own screen instead, set `updateRequiredViewEnabled={false}` on `` and read the new `useAppVersionSupport()` hook, which returns `isSupported`, `currentVersion`, `minimumVersion`, and `updateUrl`. 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/.changeset/expo-native-components-update-screen.md b/.changeset/expo-native-components-update-screen.md index fa36f8798ad..e146e5da82d 100644 --- a/.changeset/expo-native-components-update-screen.md +++ b/.changeset/expo-native-components-update-screen.md @@ -2,4 +2,4 @@ '@clerk/expo-native-components': patch --- -Let `@clerk/expo` turn off the native iOS and Android SDKs' own update-required screens. `` turns them off when it shows its own ``, or when you set `updateRequiredViewEnabled={false}`, so an app whose version is older than its minimum sees one screen. Older versions of `@clerk/expo`, and apps without `expo-application`, which `@clerk/expo` needs to identify the app, keep the native screen. +Let `@clerk/expo` turn off the native iOS and Android SDKs' own update-required screens. `` turns them off when it shows its own update screen, or when you set `updateRequiredViewEnabled={false}`, so an app whose version is older than its minimum sees one screen. Older versions of `@clerk/expo`, and apps without `expo-application`, which `@clerk/expo` needs to identify the app, keep the native screen. diff --git a/packages/expo/src/index.ts b/packages/expo/src/index.ts index 1f4e0e6fb69..aa9223501c0 100644 --- a/packages/expo/src/index.ts +++ b/packages/expo/src/index.ts @@ -13,7 +13,6 @@ 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 ac6e210391f..78955470e05 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -54,7 +54,7 @@ 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. + * Shows an update screen over your app on iOS and Android when its version is older than the minimum set in the Clerk Dashboard. The screen 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. * * @default true */ diff --git a/packages/expo/src/update-required/UpdateRequiredView.tsx b/packages/expo/src/update-required/UpdateRequiredView.tsx index abeacb2815d..bd08cf028ad 100644 --- a/packages/expo/src/update-required/UpdateRequiredView.tsx +++ b/packages/expo/src/update-required/UpdateRequiredView.tsx @@ -10,19 +10,6 @@ import { type UpdateRequiredColors, updateRequiredColors } from './colors'; const GOOGLE_PLAY_HOST = 'play.google.com'; const MAX_ACTIONS_WIDTH = 320; -/** - * A full-screen view telling the user to update the app because its version is older than the minimum set in the Clerk Dashboard. - * - * `` shows this view over your app automatically. To place it yourself, set `updateRequiredViewEnabled={false}` on `` and render it while `useAppVersionSupport()` reports the app as unsupported. - * - * @example - * ```tsx - * const { isSupported } = useAppVersionSupport(); - * if (!isSupported) { - * return ; - * } - * ``` - */ export function UpdateRequiredView(): JSX.Element { const status = useAppVersionSupport(); const display = useSyncExternalStore( diff --git a/packages/expo/src/update-required/index.ts b/packages/expo/src/update-required/index.ts deleted file mode 100644 index 736455cbce8..00000000000 --- a/packages/expo/src/update-required/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { UpdateRequiredView } from './UpdateRequiredView';