From 9f024a45fbf78154e6dbb38286a5626dc8a2023a Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 10 Aug 2026 08:22:25 -0700 Subject: [PATCH 1/4] fix(expo): Pass proxyUrl through to the native component SDKs --- .changeset/expo-native-proxy-url.md | 5 ++ .../expo/modules/clerk/ClerkExpoModule.kt | 20 +++++--- packages/expo/ios/ClerkExpoModule.swift | 9 ++-- packages/expo/ios/ClerkNativeBridge.swift | 31 ++++++++---- packages/expo/src/provider/ClerkProvider.tsx | 1 + .../ClerkProvider.nativeClientSync.test.tsx | 49 ++++++++++++------- .../expo/src/provider/nativeClientSync.tsx | 34 ++++++++----- .../src/specs/NativeClerkModule.android.ts | 2 +- packages/expo/src/specs/NativeClerkModule.ts | 2 +- packages/expo/src/utils/native-module.ts | 2 +- 10 files changed, 103 insertions(+), 52 deletions(-) create mode 100644 .changeset/expo-native-proxy-url.md diff --git a/.changeset/expo-native-proxy-url.md b/.changeset/expo-native-proxy-url.md new file mode 100644 index 00000000000..c993ade3352 --- /dev/null +++ b/.changeset/expo-native-proxy-url.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +The native components (`AuthView`, `UserProfileView`, `UserButtonView`) now respect the `proxyUrl` passed to `` and route Frontend API requests through the configured proxy. diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index c5718ade6ef..753e2a85b0d 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -42,6 +42,7 @@ class ClerkExpoModule : Module() { private var lastObservedClientState: ClientStateSnapshot? = null private var jsOriginatedClientSyncDepth = 0 private var configuredPublishableKey: String? = null + private var configuredProxyUrl: String? = null private data class ClientStateSnapshot( val client: Client?, @@ -85,8 +86,8 @@ class ClerkExpoModule : Module() { clientStateObserverJob = null } - AsyncFunction("configure") { pubKey: String, bearerToken: String?, promise: Promise -> - configure(pubKey, bearerToken, promise) + AsyncFunction("configure") { pubKey: String, bearerToken: String?, proxyUrl: String?, promise: Promise -> + configure(pubKey, bearerToken, proxyUrl, promise) } AsyncFunction("getClientToken") { promise: Promise -> @@ -112,7 +113,7 @@ class ClerkExpoModule : Module() { private val reactContext: Context? get() = appContext.reactContext - private fun clerkConfigurationOptions(): ClerkConfigurationOptions { + private fun clerkConfigurationOptions(proxyUrl: String?): ClerkConfigurationOptions { val hostSdkVersion = BuildConfig.CLERK_EXPO_VERSION.trim() val customHeaders = buildMap { put(HOST_SDK_HEADER, HOST_SDK) @@ -121,7 +122,7 @@ class ClerkExpoModule : Module() { } } - return ClerkConfigurationOptions().withCustomHeaders(customHeaders) + return ClerkConfigurationOptions(proxyUrl = proxyUrl).withCustomHeaders(customHeaders) } private fun startClientStateObserver() { @@ -207,7 +208,7 @@ class ClerkExpoModule : Module() { // MARK: - configure - private fun configure(pubKey: String, bearerToken: String?, promise: Promise) { + private fun configure(pubKey: String, bearerToken: String?, proxyUrl: String?, promise: Promise) { val context = reactContext ?: run { promise.reject("E_INIT_FAILED", "React context is not available", null) return @@ -216,6 +217,7 @@ class ClerkExpoModule : Module() { coroutineScope.launch { try { val normalizedBearerToken = bearerToken?.trim()?.takeIf { it.isNotEmpty() } + val normalizedProxyUrl = proxyUrl?.trim()?.takeIf { it.isNotEmpty() } if (!Clerk.isInitialized.value) { // First-time initialization — write the bearer token to SharedPreferences @@ -227,7 +229,7 @@ class ClerkExpoModule : Module() { .apply() } - Clerk.initialize(context, pubKey, clerkConfigurationOptions()) + Clerk.initialize(context, pubKey, clerkConfigurationOptions(normalizedProxyUrl)) startClientStateObserver() // clerk-android registers ActivityLifecycleCallbacks during // initialize(), but in React Native MainActivity has already passed @@ -272,6 +274,7 @@ class ClerkExpoModule : Module() { promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}", null) } else { configuredPublishableKey = pubKey + configuredProxyUrl = normalizedProxyUrl lastObservedClientState = clientStateSnapshot() promise.resolve(null) } @@ -279,8 +282,8 @@ class ClerkExpoModule : Module() { } val activePublishableKey = configuredPublishableKey ?: Clerk.publishableKey - if (activePublishableKey != null && activePublishableKey != pubKey) { - Clerk.switchConfiguration(context, pubKey, clerkConfigurationOptions()) + if (activePublishableKey != null && (activePublishableKey != pubKey || configuredProxyUrl != normalizedProxyUrl)) { + Clerk.switchConfiguration(context, pubKey, clerkConfigurationOptions(normalizedProxyUrl)) startClientStateObserver() appContext.currentActivity?.let { Clerk.attachActivity(it) } loadThemeFromAssets(context) @@ -325,6 +328,7 @@ class ClerkExpoModule : Module() { } configuredPublishableKey = pubKey + configuredProxyUrl = normalizedProxyUrl lastObservedClientState = clientStateSnapshot() promise.resolve(null) return@launch diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index 8c2f964ec2d..361c96772a7 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -31,8 +31,8 @@ public class ClerkExpoModule: Module { } } - AsyncFunction("configure") { (publishableKey: String, bearerToken: String?, promise: Promise) in - self.configure(publishableKey, bearerToken: bearerToken, promise: promise) + AsyncFunction("configure") { (publishableKey: String, bearerToken: String?, proxyUrl: String?, promise: Promise) in + self.configure(publishableKey, bearerToken: bearerToken, proxyUrl: proxyUrl, promise: promise) } AsyncFunction("getClientToken") { (promise: Promise) in @@ -57,10 +57,11 @@ public class ClerkExpoModule: Module { // MARK: - configure - private func configure(_ publishableKey: String, bearerToken: String?, promise: Promise) { + private func configure(_ publishableKey: String, bearerToken: String?, proxyUrl: String?, promise: Promise) { Task { do { - try await ClerkNativeBridge.shared.configure(publishableKey: publishableKey, bearerToken: bearerToken) + try await ClerkNativeBridge.shared.configure( + publishableKey: publishableKey, bearerToken: bearerToken, proxyUrl: proxyUrl) promise.resolve() } catch { promise.reject("E_CONFIGURE_FAILED", error.localizedDescription) diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index fccd156a501..886d60b2ee4 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -67,6 +67,7 @@ final class ClerkNativeBridge { private static let clerkLoadIntervalNs: UInt64 = 100_000_000 private static var clerkConfigured = false private static var configuredPublishableKey: String? + private static var configuredProxyUrl: String? /// Parsed light and dark themes from Info.plist "ClerkTheme" dictionary. var lightTheme: ClerkTheme? @@ -101,19 +102,23 @@ final class ClerkNativeBridge { } @MainActor - func configure(publishableKey: String, bearerToken: String? = nil) async throws { + func configure(publishableKey: String, bearerToken: String? = nil, proxyUrl: String? = nil) async throws { configurationDepth += 1 defer { lastObservedClientState = Self.clerkConfigured ? Self.clientStateSnapshot() : nil configurationDepth = max(0, configurationDepth - 1) } + let normalizedProxyUrl = Self.normalizedProxyUrl(proxyUrl) + loadThemes() - if Self.shouldReconfigure(for: publishableKey) { - try await Clerk.reconfigure(publishableKey: publishableKey, options: Self.makeClerkOptions()) + if Self.shouldReconfigure(for: publishableKey, proxyUrl: normalizedProxyUrl) { + try await Clerk.reconfigure( + publishableKey: publishableKey, options: Self.makeClerkOptions(proxyUrl: normalizedProxyUrl)) Self.clerkConfigured = true Self.configuredPublishableKey = publishableKey + Self.configuredProxyUrl = normalizedProxyUrl startClientObserver(reset: true) let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken) @@ -138,7 +143,8 @@ final class ClerkNativeBridge { Self.clerkConfigured = true Self.configuredPublishableKey = publishableKey - Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions()) + Self.configuredProxyUrl = normalizedProxyUrl + Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions(proxyUrl: normalizedProxyUrl)) startClientObserver() let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken) @@ -227,17 +233,24 @@ final class ClerkNativeBridge { return true } - private static func shouldReconfigure(for publishableKey: String) -> Bool { + private static func shouldReconfigure(for publishableKey: String, proxyUrl: String?) -> Bool { guard clerkConfigured, let configuredPublishableKey else { return false } - return configuredPublishableKey != publishableKey + return configuredPublishableKey != publishableKey || configuredProxyUrl != proxyUrl + } + + private static func normalizedProxyUrl(_ proxyUrl: String?) -> String? { + guard let trimmed = proxyUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed } - private static func makeClerkOptions() -> Clerk.Options { + private static func makeClerkOptions(proxyUrl: String?) -> Clerk.Options { let middleware = Clerk.Options.MiddlewareConfig(request: [ClerkExpoHeaderMiddleware()]) guard let service = keychainService else { - return .init(middleware: middleware) + return .init(proxyUrl: proxyUrl, middleware: middleware) } - return .init(keychainConfig: .init(service: service), middleware: middleware) + return .init(keychainConfig: .init(service: service), proxyUrl: proxyUrl, middleware: middleware) } @MainActor diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 720f424031c..87ecaa636e2 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -92,6 +92,7 @@ export function ClerkProvider(props: ClerkProviderProps { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token'); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token', null); }); expect(mocks.configure).toHaveBeenCalledTimes(1); expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); }); + test('passes the proxyUrl to the native configure call', async () => { + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, 'https://example.com/api/__clerk'); + }); + expect(mocks.configure).toHaveBeenCalledTimes(1); + }); + test('syncs the native device token to JS after Clerk loads during bootstrap', async () => { mocks.clerkInstance.loaded = false; mocks.clerkInstance.status = 'loading'; @@ -224,7 +239,7 @@ describe('ClerkProvider native client sync', () => { }); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); }); expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); @@ -245,7 +260,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'cached-client-token'); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'cached-client-token', null); }); expect(mocks.configure).toHaveBeenCalledTimes(1); expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); @@ -338,7 +353,7 @@ describe('ClerkProvider native client sync', () => { expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); }); expect(mocks.configure).toHaveBeenCalledTimes(1); - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalledTimes(1); }); @@ -372,7 +387,7 @@ describe('ClerkProvider native client sync', () => { render(); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); }); mocks.syncClientStateFromJs.mockClear(); @@ -554,7 +569,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); }); mocks.syncClientStateFromJs.mockClear(); @@ -1389,7 +1404,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); }); mocks.syncClientStateFromJs.mockClear(); @@ -1420,7 +1435,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); }); act(() => { @@ -1458,7 +1473,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); }); await act(async () => { @@ -1493,7 +1508,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); }); mocks.syncClientStateFromJs.mockClear(); @@ -1518,7 +1533,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); }); mocks.syncClientStateFromJs.mockClear(); @@ -1614,7 +1629,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken, null); }); mocks.syncClientStateFromJs.mockClear(); @@ -1675,7 +1690,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken, null); }); mocks.syncClientStateFromJs.mockClear(); @@ -1734,7 +1749,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken, null); }); mocks.syncClientStateFromJs.mockClear(); @@ -1797,7 +1812,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken, null); }); mocks.tokenCache.saveToken.mockClear(); @@ -1856,7 +1871,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'js-device-token'); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'js-device-token', null); }); await waitFor(() => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); @@ -1911,7 +1926,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'js-device-token'); + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'js-device-token', null); }); mocks.tokenCache.getToken.mockImplementation(() => new Promise(() => {})); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 5c5bca2c978..d2c9e4de0d1 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -963,20 +963,25 @@ function waitForClerkInstanceLoad(clerkInstance: SyncableClerkInstance): Promise export function useNativeClientBootstrap({ publishableKey, + proxyUrl, nativeRefreshFromJsControllerRef, suppressTokenCacheNotificationsRef, tokenCache, clerkInstance, }: { publishableKey: string; + proxyUrl?: string | ((url: URL) => string); nativeRefreshFromJsControllerRef: MutableRefObject; suppressTokenCacheNotificationsRef: MutableRefObject; tokenCache: TokenCache | undefined; clerkInstance: SyncableClerkInstance | null | undefined; }) { - const startedPublishableKeyRef = useRef(null); + const startedConfigKeyRef = useRef(null); const isMountedRef = useRef(true); - const [readyPublishableKey, setReadyPublishableKey] = useState(null); + const [readyConfigKey, setReadyConfigKey] = useState(null); + // Function proxyUrls are browser-only; the singleton already rejects them on native. + const nativeProxyUrl = typeof proxyUrl === 'string' && proxyUrl ? proxyUrl : null; + const configKey = `${publishableKey}|${nativeProxyUrl ?? ''}`; useEffect(() => { isMountedRef.current = true; @@ -984,12 +989,11 @@ export function useNativeClientBootstrap({ if ( (Platform.OS === 'ios' || Platform.OS === 'android') && publishableKey && - startedPublishableKeyRef.current !== publishableKey + startedConfigKeyRef.current !== configKey ) { - startedPublishableKeyRef.current = publishableKey; - const configuringPublishableKey = publishableKey; - const isCurrentConfiguration = () => - isMountedRef.current && startedPublishableKeyRef.current === configuringPublishableKey; + startedConfigKeyRef.current = configKey; + const configuringConfigKey = configKey; + const isCurrentConfiguration = () => isMountedRef.current && startedConfigKeyRef.current === configuringConfigKey; const configureNativeClerk = async () => { let didAttemptConfigure = false; @@ -1019,7 +1023,7 @@ export function useNativeClientBootstrap({ } didAttemptConfigure = true; - await ClerkExpo.configure(configuringPublishableKey, initialJsDeviceToken); + await ClerkExpo.configure(publishableKey, initialJsDeviceToken, nativeProxyUrl); if (!isCurrentConfiguration()) { return; @@ -1073,7 +1077,7 @@ export function useNativeClientBootstrap({ } } finally { if (didAttemptConfigure && isCurrentConfiguration()) { - setReadyPublishableKey(configuringPublishableKey); + setReadyConfigKey(configuringConfigKey); } } }; @@ -1083,11 +1087,19 @@ export function useNativeClientBootstrap({ return () => { isMountedRef.current = false; }; - }, [publishableKey, nativeRefreshFromJsControllerRef, suppressTokenCacheNotificationsRef, tokenCache, clerkInstance]); + }, [ + publishableKey, + configKey, + nativeProxyUrl, + nativeRefreshFromJsControllerRef, + suppressTokenCacheNotificationsRef, + tokenCache, + clerkInstance, + ]); return { isMountedRef, - isNativeClientReady: readyPublishableKey === publishableKey, + isNativeClientReady: readyConfigKey === configKey, }; } diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index cced94ad12a..a8de6a12e1d 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -4,7 +4,7 @@ interface Spec { // Exposed by Expo Modules EventEmitter for internal native client change events. // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; - configure(publishableKey: string, bearerToken: string | null): Promise; + configure(publishableKey: string, bearerToken: string | null, proxyUrl: string | null): Promise; getClientToken(): Promise; syncClientStateFromJs( deviceToken: string | null, diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index c8eb967e84d..c51a0ded38b 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -4,7 +4,7 @@ export interface Spec { // Exposed by Expo Modules EventEmitter for internal native client change events. // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; - configure(publishableKey: string, bearerToken: string | null): Promise; + configure(publishableKey: string, bearerToken: string | null, proxyUrl: string | null): Promise; getClientToken(): Promise; syncClientStateFromJs( deviceToken: string | null, diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts index 1a852882e4e..bafcfc92027 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -6,7 +6,7 @@ export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'andro type ClerkExpoNativeModule = { addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; - configure(publishableKey: string, bearerToken: string | null): Promise; + configure(publishableKey: string, bearerToken: string | null, proxyUrl: string | null): Promise; getClientToken(): Promise; syncClientStateFromJs( deviceToken: string | null, From 54c14a5401906a6e16850eee4e2c4daef9069764 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Mon, 10 Aug 2026 08:23:54 -0700 Subject: [PATCH 2/4] chore: update changeset --- .changeset/expo-native-proxy-url.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/expo-native-proxy-url.md b/.changeset/expo-native-proxy-url.md index c993ade3352..6111921cf7d 100644 --- a/.changeset/expo-native-proxy-url.md +++ b/.changeset/expo-native-proxy-url.md @@ -2,4 +2,4 @@ '@clerk/expo': patch --- -The native components (`AuthView`, `UserProfileView`, `UserButtonView`) now respect the `proxyUrl` passed to `` and route Frontend API requests through the configured proxy. +Native components now respect the `proxyUrl` passed to `` and route Frontend API requests through the configured proxy. From cb3dd3b880e6a4fc15c4e3f56b66040a752385fc Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 10 Aug 2026 08:49:49 -0700 Subject: [PATCH 3/4] fix(expo): feature-detect native proxy support to stay OTA-compatible --- .changeset/expo-native-proxy-url.md | 2 +- .../expo/modules/clerk/ClerkExpoModule.kt | 18 ++- packages/expo/ios/ClerkExpoModule.swift | 17 ++- .../ClerkProvider.nativeClientSync.test.tsx | 140 ++++++++++++------ .../expo/src/provider/nativeClientSync.tsx | 17 ++- .../src/specs/NativeClerkModule.android.ts | 8 +- packages/expo/src/specs/NativeClerkModule.ts | 8 +- packages/expo/src/utils/native-module.ts | 6 +- 8 files changed, 164 insertions(+), 52 deletions(-) diff --git a/.changeset/expo-native-proxy-url.md b/.changeset/expo-native-proxy-url.md index 6111921cf7d..702e2f6d333 100644 --- a/.changeset/expo-native-proxy-url.md +++ b/.changeset/expo-native-proxy-url.md @@ -2,4 +2,4 @@ '@clerk/expo': patch --- -Native components now respect the `proxyUrl` passed to `` and route Frontend API requests through the configured proxy. +Native components now respect the `proxyUrl` passed to `` and route Frontend API requests through the configured proxy. Applying the proxy requires a new app binary; a JS-only OTA update safely keeps the previous behavior. diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index 753e2a85b0d..5ddf5ad3e6f 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -15,6 +15,8 @@ import com.clerk.api.ui.ClerkTheme import expo.modules.kotlin.Promise import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.records.Field +import expo.modules.kotlin.records.Record import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -36,6 +38,12 @@ private fun debugLog(tag: String, message: String) { } } +internal class ConfigureOptions : Record { + @Field val bearerToken: String? = null + + @Field val proxyUrl: String? = null +} + class ClerkExpoModule : Module() { private val coroutineScope = CoroutineScope(Dispatchers.Main) private var clientStateObserverJob: Job? = null @@ -86,8 +94,14 @@ class ClerkExpoModule : Module() { clientStateObserverJob = null } - AsyncFunction("configure") { pubKey: String, bearerToken: String?, proxyUrl: String?, promise: Promise -> - configure(pubKey, bearerToken, proxyUrl, promise) + // Kept with the pre-proxy signature so OTA-updated JS bundles built against older + // SDK versions keep working; new bundles feature-detect configureWithOptions. + AsyncFunction("configure") { pubKey: String, bearerToken: String?, promise: Promise -> + configure(pubKey, bearerToken, null, promise) + } + + AsyncFunction("configureWithOptions") { pubKey: String, options: ConfigureOptions, promise: Promise -> + configure(pubKey, options.bearerToken, options.proxyUrl, promise) } AsyncFunction("getClientToken") { promise: Promise -> diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index 361c96772a7..b5c40fb08a6 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -5,6 +5,13 @@ import ExpoModulesCore import Foundation +// MARK: - Records + +struct ConfigureOptions: Record { + @Field var bearerToken: String? + @Field var proxyUrl: String? +} + // MARK: - Module public class ClerkExpoModule: Module { @@ -31,8 +38,14 @@ public class ClerkExpoModule: Module { } } - AsyncFunction("configure") { (publishableKey: String, bearerToken: String?, proxyUrl: String?, promise: Promise) in - self.configure(publishableKey, bearerToken: bearerToken, proxyUrl: proxyUrl, promise: promise) + // Kept with the pre-proxy signature so OTA-updated JS bundles built against older + // SDK versions keep working; new bundles feature-detect configureWithOptions. + AsyncFunction("configure") { (publishableKey: String, bearerToken: String?, promise: Promise) in + self.configure(publishableKey, bearerToken: bearerToken, proxyUrl: nil, promise: promise) + } + + AsyncFunction("configureWithOptions") { (publishableKey: String, options: ConfigureOptions, promise: Promise) in + self.configure(publishableKey, bearerToken: options.bearerToken, proxyUrl: options.proxyUrl, promise: promise) } AsyncFunction("getClientToken") { (promise: Promise) in diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index 796d0276592..b2843f6b10c 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -3,11 +3,13 @@ import React, { type ReactNode } from 'react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { CLERK_CLIENT_JWT_KEY } from '../../constants'; +import NativeClerkModule from '../../specs/NativeClerkModule'; import { ClerkProvider } from '../ClerkProvider'; const mocks = vi.hoisted(() => { return { configure: vi.fn(), + configureWithOptions: vi.fn(), getClientToken: vi.fn(), nativeClientEvent: null as unknown, syncClientStateFromJs: vi.fn(), @@ -95,6 +97,7 @@ vi.mock('../../specs/NativeClerkModule', () => { default: { addListener: vi.fn(), configure: mocks.configure, + configureWithOptions: mocks.configureWithOptions, getClientToken: mocks.getClientToken, syncClientStateFromJs: mocks.syncClientStateFromJs, }, @@ -129,7 +132,10 @@ describe('ClerkProvider native client sync', () => { beforeEach(() => { vi.clearAllMocks(); mocks.nativeClientEvent = null; + (NativeClerkModule as unknown as { configureWithOptions?: unknown }).configureWithOptions = + mocks.configureWithOptions; mocks.configure.mockResolvedValue(undefined); + mocks.configureWithOptions.mockResolvedValue(undefined); mocks.getClientToken.mockResolvedValue(null); mocks.syncClientStateFromJs.mockResolvedValue(undefined); mocks.tokenCache.getToken.mockResolvedValue(null); @@ -186,9 +192,12 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token', null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: 'client-token', + proxyUrl: null, + }); }); - expect(mocks.configure).toHaveBeenCalledTimes(1); + expect(mocks.configureWithOptions).toHaveBeenCalledTimes(1); expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); }); @@ -203,9 +212,33 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, 'https://example.com/api/__clerk'); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: null, + proxyUrl: 'https://example.com/api/__clerk', + }); + }); + expect(mocks.configureWithOptions).toHaveBeenCalledTimes(1); + expect(mocks.configure).not.toHaveBeenCalled(); + }); + + test('falls back to the legacy configure signature when the binary lacks configureWithOptions', async () => { + delete (NativeClerkModule as unknown as { configureWithOptions?: unknown }).configureWithOptions; + mocks.tokenCache.getToken.mockResolvedValue('client-token'); + mocks.getClientToken.mockResolvedValue('client-token'); + + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token'); }); expect(mocks.configure).toHaveBeenCalledTimes(1); + expect(mocks.configureWithOptions).not.toHaveBeenCalled(); }); test('syncs the native device token to JS after Clerk loads during bootstrap', async () => { @@ -223,7 +256,7 @@ describe('ClerkProvider native client sync', () => { await waitFor(() => { expect(mocks.clerkInstance.on).toHaveBeenCalledWith('status', expect.any(Function)); }); - expect(mocks.configure).not.toHaveBeenCalled(); + expect(mocks.configureWithOptions).not.toHaveBeenCalled(); expect(mocks.getClientToken).not.toHaveBeenCalled(); expect(mocks.tokenCache.saveToken).not.toHaveBeenCalled(); expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); @@ -239,7 +272,7 @@ describe('ClerkProvider native client sync', () => { }); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); }); expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); @@ -248,7 +281,7 @@ describe('ClerkProvider native client sync', () => { test('syncs a JS token rotated during bootstrap to native exactly once', async () => { const configure = deferred(); - mocks.configure.mockReturnValue(configure.promise); + mocks.configureWithOptions.mockReturnValue(configure.promise); mocks.tokenCache.getToken.mockResolvedValueOnce('cached-client-token').mockResolvedValue('rotated-client-token'); mocks.getClientToken.mockResolvedValue('native-client-token'); @@ -260,9 +293,12 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'cached-client-token', null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: 'cached-client-token', + proxyUrl: null, + }); }); - expect(mocks.configure).toHaveBeenCalledTimes(1); + expect(mocks.configureWithOptions).toHaveBeenCalledTimes(1); expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); act(() => { @@ -283,7 +319,7 @@ describe('ClerkProvider native client sync', () => { test('flushes one JS client change that occurs after JS loads but before native is ready', async () => { const configure = deferred(); - mocks.configure.mockReturnValue(configure.promise); + mocks.configureWithOptions.mockReturnValue(configure.promise); render( { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledTimes(1); + expect(mocks.configureWithOptions).toHaveBeenCalledTimes(1); expect(mocks.clerkInstance.addListener).toHaveBeenCalled(); }); @@ -314,7 +350,7 @@ describe('ClerkProvider native client sync', () => { test('keeps synchronization enabled when native configure rejects', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); - mocks.configure.mockRejectedValue(new Error('native refresh failed')); + mocks.configureWithOptions.mockRejectedValue(new Error('native refresh failed')); render( { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledTimes(1); + expect(mocks.configureWithOptions).toHaveBeenCalledTimes(1); }); act(() => { @@ -352,8 +388,8 @@ describe('ClerkProvider native client sync', () => { await waitFor(() => { expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); }); - expect(mocks.configure).toHaveBeenCalledTimes(1); - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); + expect(mocks.configureWithOptions).toHaveBeenCalledTimes(1); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalledTimes(1); }); @@ -387,7 +423,7 @@ describe('ClerkProvider native client sync', () => { render(); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); }); mocks.syncClientStateFromJs.mockClear(); @@ -410,7 +446,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); @@ -447,7 +483,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); @@ -485,7 +521,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); @@ -523,7 +559,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.syncClientStateFromJs.mockClear(); @@ -569,7 +605,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); }); mocks.syncClientStateFromJs.mockClear(); @@ -665,7 +701,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.nativeClientEvent = { @@ -728,7 +764,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.setActive.mockClear(); @@ -787,7 +823,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.setActive.mockClear(); @@ -872,7 +908,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); originalUpdateClient.mockClear(); @@ -940,7 +976,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); await waitFor(() => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); @@ -992,7 +1028,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); await waitFor(() => { expect(mocks.clerkInstance.updateClient).not.toBe(originalUpdateClient); @@ -1108,7 +1144,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); await waitFor(() => { expect(mocks.clerkInstance.updateClient).not.toBe(originalUpdateClient); @@ -1189,7 +1225,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); await waitFor(() => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); @@ -1236,7 +1272,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); await waitFor(() => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); @@ -1274,7 +1310,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); await waitFor(() => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); @@ -1317,7 +1353,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); await waitFor(() => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); @@ -1371,7 +1407,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); await waitFor(() => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); @@ -1404,7 +1440,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); }); mocks.syncClientStateFromJs.mockClear(); @@ -1435,7 +1471,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); }); act(() => { @@ -1473,7 +1509,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); }); await act(async () => { @@ -1508,7 +1544,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); }); mocks.syncClientStateFromJs.mockClear(); @@ -1533,7 +1569,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null, null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); }); mocks.syncClientStateFromJs.mockClear(); @@ -1629,7 +1665,10 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken, null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: jsDeviceToken, + proxyUrl: null, + }); }); mocks.syncClientStateFromJs.mockClear(); @@ -1690,7 +1729,10 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken, null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: jsDeviceToken, + proxyUrl: null, + }); }); mocks.syncClientStateFromJs.mockClear(); @@ -1749,7 +1791,10 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken, null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: jsDeviceToken, + proxyUrl: null, + }); }); mocks.syncClientStateFromJs.mockClear(); @@ -1812,7 +1857,10 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken, null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: jsDeviceToken, + proxyUrl: null, + }); }); mocks.tokenCache.saveToken.mockClear(); @@ -1871,7 +1919,10 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'js-device-token', null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: 'js-device-token', + proxyUrl: null, + }); }); await waitFor(() => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); @@ -1926,7 +1977,10 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'js-device-token', null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: 'js-device-token', + proxyUrl: null, + }); }); mocks.tokenCache.getToken.mockImplementation(() => new Promise(() => {})); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index d2c9e4de0d1..42d8d918288 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -1023,7 +1023,22 @@ export function useNativeClientBootstrap({ } didAttemptConfigure = true; - await ClerkExpo.configure(publishableKey, initialJsDeviceToken, nativeProxyUrl); + if (typeof ClerkExpo.configureWithOptions === 'function') { + await ClerkExpo.configureWithOptions(publishableKey, { + bearerToken: initialJsDeviceToken, + proxyUrl: nativeProxyUrl, + }); + } else { + // Binary predates proxy support; OTA-updated JS must keep the legacy 2-arg call + // because Expo Modules rejects calls with more arguments than the binary declares. + if (nativeProxyUrl && __DEV__) { + console.warn( + '[ClerkProvider] The installed Clerk native module does not support proxyUrl. ' + + 'Rebuild the app binary to route native components through your proxy.', + ); + } + await ClerkExpo.configure(publishableKey, initialJsDeviceToken); + } if (!isCurrentConfiguration()) { return; diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index a8de6a12e1d..746d84e53bd 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -4,7 +4,13 @@ interface Spec { // Exposed by Expo Modules EventEmitter for internal native client change events. // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; - configure(publishableKey: string, bearerToken: string | null, proxyUrl: string | null): Promise; + configure(publishableKey: string, bearerToken: string | null): Promise; + // Optional: absent on binaries built before proxy support; callers must feature-detect and + // fall back to configure(), otherwise OTA-updated JS breaks on older binaries. + configureWithOptions?( + publishableKey: string, + options: { bearerToken: string | null; proxyUrl: string | null }, + ): Promise; getClientToken(): Promise; syncClientStateFromJs( deviceToken: string | null, diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index c51a0ded38b..ebd45372238 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -4,7 +4,13 @@ export interface Spec { // Exposed by Expo Modules EventEmitter for internal native client change events. // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; - configure(publishableKey: string, bearerToken: string | null, proxyUrl: string | null): Promise; + configure(publishableKey: string, bearerToken: string | null): Promise; + // Optional: absent on binaries built before proxy support; callers must feature-detect and + // fall back to configure(), otherwise OTA-updated JS breaks on older binaries. + configureWithOptions?( + publishableKey: string, + options: { bearerToken: string | null; proxyUrl: string | null }, + ): Promise; getClientToken(): Promise; syncClientStateFromJs( deviceToken: string | null, diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts index bafcfc92027..14a476c1578 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -6,7 +6,11 @@ export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'andro type ClerkExpoNativeModule = { addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; - configure(publishableKey: string, bearerToken: string | null, proxyUrl: string | null): Promise; + configure(publishableKey: string, bearerToken: string | null): Promise; + configureWithOptions?( + publishableKey: string, + options: { bearerToken: string | null; proxyUrl: string | null }, + ): Promise; getClientToken(): Promise; syncClientStateFromJs( deviceToken: string | null, From cfb64951dc6a2a47af7971bbff2b18dd66b59326 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 10 Aug 2026 08:51:43 -0700 Subject: [PATCH 4/4] fix(expo): condense bridge compat comments --- .../src/main/java/expo/modules/clerk/ClerkExpoModule.kt | 3 +-- packages/expo/ios/ClerkExpoModule.swift | 3 +-- packages/expo/src/provider/nativeClientSync.tsx | 3 +-- packages/expo/src/specs/NativeClerkModule.android.ts | 3 +-- packages/expo/src/specs/NativeClerkModule.ts | 3 +-- 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index 5ddf5ad3e6f..406d66a6dce 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -94,8 +94,7 @@ class ClerkExpoModule : Module() { clientStateObserverJob = null } - // Kept with the pre-proxy signature so OTA-updated JS bundles built against older - // SDK versions keep working; new bundles feature-detect configureWithOptions. + // Keeps the pre-proxy signature so OTA-updated JS on older binaries keeps working. AsyncFunction("configure") { pubKey: String, bearerToken: String?, promise: Promise -> configure(pubKey, bearerToken, null, promise) } diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index b5c40fb08a6..65f00bd4852 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -38,8 +38,7 @@ public class ClerkExpoModule: Module { } } - // Kept with the pre-proxy signature so OTA-updated JS bundles built against older - // SDK versions keep working; new bundles feature-detect configureWithOptions. + // Keeps the pre-proxy signature so OTA-updated JS on older binaries keeps working. AsyncFunction("configure") { (publishableKey: String, bearerToken: String?, promise: Promise) in self.configure(publishableKey, bearerToken: bearerToken, proxyUrl: nil, promise: promise) } diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 42d8d918288..7192e732176 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -1029,8 +1029,7 @@ export function useNativeClientBootstrap({ proxyUrl: nativeProxyUrl, }); } else { - // Binary predates proxy support; OTA-updated JS must keep the legacy 2-arg call - // because Expo Modules rejects calls with more arguments than the binary declares. + // Old binaries reject extra configure args, so OTA-updated JS must use the legacy call. if (nativeProxyUrl && __DEV__) { console.warn( '[ClerkProvider] The installed Clerk native module does not support proxyUrl. ' + diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index 746d84e53bd..aa18caebf35 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -5,8 +5,7 @@ interface Spec { // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; configure(publishableKey: string, bearerToken: string | null): Promise; - // Optional: absent on binaries built before proxy support; callers must feature-detect and - // fall back to configure(), otherwise OTA-updated JS breaks on older binaries. + // Absent on binaries built before proxy support; feature-detect and fall back to configure(). configureWithOptions?( publishableKey: string, options: { bearerToken: string | null; proxyUrl: string | null }, diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index ebd45372238..390daf07527 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -5,8 +5,7 @@ export interface Spec { // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; configure(publishableKey: string, bearerToken: string | null): Promise; - // Optional: absent on binaries built before proxy support; callers must feature-detect and - // fall back to configure(), otherwise OTA-updated JS breaks on older binaries. + // Absent on binaries built before proxy support; feature-detect and fall back to configure(). configureWithOptions?( publishableKey: string, options: { bearerToken: string | null; proxyUrl: string | null },