Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6da3129
feat(expo): block app versions older than the minimum set in the Cler…
seanperez29 Oct 21, 2026
bc4b7c8
fix(expo): reload when a blocked app returns to the foreground
seanperez29 Oct 21, 2026
772f327
chore(expo): dedupe the lockfile
seanperez29 Oct 21, 2026
1eeded4
fix(expo): restart Clerk when the app version block lifts after a fai…
seanperez29 Oct 21, 2026
9ac3308
fix(expo): keep the default API version, since minimum versions apply…
seanperez29 Oct 22, 2026
2da5e0c
fix(expo): let only a rejection outlive an environment fetch
seanperez29 Oct 22, 2026
1fc8c09
fix(expo): keep the environment's minimum when a response marks the a…
seanperez29 Oct 22, 2026
0b7e9ac
refactor(expo): trim leftovers in the app version support code
seanperez29 Oct 22, 2026
1c199e8
fix(expo): let the server's latest answer outrank the environment's m…
seanperez29 Oct 22, 2026
43eb2fd
fix(expo): drop the account-security reason from the update screen
seanperez29 Oct 23, 2026
c8c932d
fix(expo): turn off the native SDKs' update screens
seanperez29 Oct 23, 2026
0ea9bb0
refactor(expo): name the app version request hooks' return type
seanperez29 Oct 23, 2026
61c5660
fix(expo): notify app version subscribers after a reset, not during it
seanperez29 Oct 23, 2026
7c125e2
fix(expo): keep the native update screen unless @clerk/expo replaces it
seanperez29 Oct 23, 2026
c1be59f
refactor(expo): read the native update screen setting when native con…
seanperez29 Oct 23, 2026
9b256bd
fix(expo): keep UpdateRequiredView internal
seanperez29 Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/expo-native-app-minimum-versions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@clerk/expo': minor
---

Support the minimum app versions set in the Clerk Dashboard. When your iOS or Android app's version is older than its minimum, Clerk rejects the app's requests until the user updates, and `<ClerkProvider>` shows 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 `<ClerkProvider>` 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.
5 changes: 5 additions & 0 deletions .changeset/expo-native-components-update-screen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo-native-components': patch
---

Let `@clerk/expo` turn off the native iOS and Android SDKs' own update-required screens. `<ClerkProvider>` 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.
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class ClerkExpoModule : Module() {
emit = { sendEvent(NATIVE_CLIENT_INVALIDATED_EVENT, emptyMap<String, Any?>()) }
)
private var configuredProxyUrl: String? = null
@Volatile private var updateRequiredViewEnabled = true

private data class AuthFlowStateSnapshot(
val isLoaded: Boolean,
Expand Down Expand Up @@ -80,6 +81,10 @@ class ClerkExpoModule : Module() {
configureNative(pubKey, seedDeviceToken, proxyUrl, promise)
}

AsyncFunction("setUpdateRequiredViewEnabled") { enabled: Boolean ->
updateRequiredViewEnabled = enabled
}

AsyncFunction("getDeviceToken") { promise: Promise ->
getDeviceToken(promise)
}
Expand Down Expand Up @@ -110,8 +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()
val options = ClerkConfigurationOptions(proxyUrl = proxyUrl).withForegroundRefreshDisabled()
return (if (updateRequiredViewEnabled) options else options.withUpdateRequiredViewDisabled())
.withCustomHeaders(customHeaders)
}

Expand Down
4 changes: 2 additions & 2 deletions packages/expo-native-components/ios/ClerkExpo.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/expo-native-components/ios/ClerkExpoModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
15 changes: 13 additions & 2 deletions packages/expo-native-components/ios/ClerkNativeBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -672,9 +673,19 @@ 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: updateRequiredViewEnabled)
}
return .init(keychainConfig: .init(service: service), proxyUrl: proxyUrl, middleware: middleware)
return .init(
keychainConfig: .init(service: service),
proxyUrl: proxyUrl,
middleware: middleware,
updateRequiredViewEnabled: updateRequiredViewEnabled
)
}

@MainActor
static func setUpdateRequiredViewEnabled(_ enabled: Bool) {
updateRequiredViewEnabled = enabled
}

@MainActor
Expand Down
5 changes: 5 additions & 0 deletions packages/expo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
"@types/base-64": "^1.0.2",
"esbuild": "^0.28.1",
"expo-apple-authentication": "^8.0.8",
"expo-application": "^7.0.8",
"expo-auth-session": "^7.0.11",
"expo-constants": "^18.0.13",
"expo-crypto": "^15.0.9",
Expand All @@ -154,6 +155,7 @@
"@clerk/expo-passkeys": ">=0.0.6",
"expo": "catalog:peer-expo",
"expo-apple-authentication": ">=7.0.0",
"expo-application": ">=5.0.0",
"expo-auth-session": ">=5",
"expo-constants": ">=12",
"expo-crypto": ">=12",
Expand All @@ -180,6 +182,9 @@
"expo-apple-authentication": {
"optional": true
},
"expo-application": {
"optional": true
},
"expo-auth-session": {
"optional": true
},
Expand Down
167 changes: 167 additions & 0 deletions packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';

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

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

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

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

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

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

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

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

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

createAppVersionRequestHooks().beforeRequest(requestInit);

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

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

hooks.afterResponse(requestInit, unsupportedAppVersion);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

appState.emit('active');

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