Skip to content

Commit 4c2f188

Browse files
feat(expo): add two-way JS/native session sync for expo native components (#8032)
Co-authored-by: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
1 parent 9a96d73 commit 4c2f188

9 files changed

Lines changed: 587 additions & 247 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/expo': patch
3+
---
4+
5+
Replace Android reflection hack with `Clerk.updateDeviceToken()` API and clear native session on JS sign-out

packages/expo/android/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ ext {
1818
credentialsVersion = "1.3.0"
1919
googleIdVersion = "1.1.1"
2020
kotlinxCoroutinesVersion = "1.7.3"
21-
clerkAndroidApiVersion = "1.0.6"
21+
clerkAndroidApiVersion = "1.0.9"
2222
clerkAndroidUiVersion = "1.0.9"
2323
composeVersion = "1.7.0"
2424
activityComposeVersion = "1.9.0"

packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt

Lines changed: 55 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.content.Context
55
import android.content.Intent
66
import android.util.Log
77
import com.clerk.api.Clerk
8+
import com.clerk.api.network.serialization.ClerkResult
89
import com.facebook.react.bridge.ActivityEventListener
910
import com.facebook.react.bridge.Promise
1011
import com.facebook.react.bridge.ReactApplicationContext
@@ -67,41 +68,63 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
6768
try {
6869
publishableKey = pubKey
6970

70-
// If the JS SDK has a bearer token, write it to the native SDK's
71-
// SharedPreferences so both SDKs share the same Clerk API client.
72-
if (!bearerToken.isNullOrEmpty()) {
73-
reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
74-
.edit()
75-
.putString("DEVICE_TOKEN", bearerToken)
76-
.apply()
77-
debugLog(TAG, "configure - wrote JS bearer token to native SharedPreferences")
78-
}
79-
80-
Clerk.initialize(reactApplicationContext, pubKey)
71+
if (!Clerk.isInitialized.value) {
72+
// First-time initialization — write the bearer token to SharedPreferences
73+
// before initializing so the SDK boots with the correct client.
74+
if (!bearerToken.isNullOrEmpty()) {
75+
reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
76+
.edit()
77+
.putString("DEVICE_TOKEN", bearerToken)
78+
.apply()
79+
}
8180

82-
// Wait for initialization to complete with timeout
83-
try {
84-
withTimeout(10_000L) {
85-
Clerk.isInitialized.first { it }
81+
Clerk.initialize(reactApplicationContext, pubKey)
82+
83+
// Wait for initialization to complete with timeout
84+
try {
85+
withTimeout(10_000L) {
86+
Clerk.isInitialized.first { it }
87+
}
88+
} catch (e: TimeoutCancellationException) {
89+
val initError = Clerk.initializationError.value
90+
val message = if (initError != null) {
91+
"Clerk initialization timed out: ${initError.message}"
92+
} else {
93+
"Clerk initialization timed out after 10 seconds"
94+
}
95+
promise.reject("E_TIMEOUT", message)
96+
return@launch
8697
}
87-
} catch (e: TimeoutCancellationException) {
88-
val initError = Clerk.initializationError.value
89-
val message = if (initError != null) {
90-
"Clerk initialization timed out: ${initError.message}"
98+
99+
// Check for initialization errors
100+
val error = Clerk.initializationError.value
101+
if (error != null) {
102+
promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}")
91103
} else {
92-
"Clerk initialization timed out after 10 seconds"
104+
promise.resolve(null)
93105
}
94-
promise.reject("E_TIMEOUT", message)
95106
return@launch
96107
}
97108

98-
// Check for initialization errors
99-
val error = Clerk.initializationError.value
100-
if (error != null) {
101-
promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}")
102-
} else {
103-
promise.resolve(null)
109+
// Already initialized — use the public SDK API to update
110+
// the device token and trigger a client/environment refresh.
111+
if (!bearerToken.isNullOrEmpty()) {
112+
val result = Clerk.updateDeviceToken(bearerToken)
113+
if (result is ClerkResult.Failure) {
114+
debugLog(TAG, "configure - updateDeviceToken failed: ${result.error}")
115+
}
116+
117+
// Wait for session to appear with the new token (up to 5s)
118+
try {
119+
withTimeout(5_000L) {
120+
Clerk.sessionFlow.first { it != null }
121+
}
122+
} catch (_: TimeoutCancellationException) {
123+
debugLog(TAG, "configure - session did not appear after token update")
124+
}
104125
}
126+
127+
promise.resolve(null)
105128
} catch (e: Exception) {
106129
promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${e.message}", e)
107130
}
@@ -174,15 +197,15 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
174197
@ReactMethod
175198
override fun getSession(promise: Promise) {
176199
if (!Clerk.isInitialized.value) {
177-
promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.")
200+
// Return null when not initialized (matches iOS behavior)
201+
// so callers can proceed to call configure() with a bearer token.
202+
promise.resolve(null)
178203
return
179204
}
180205

181206
val session = Clerk.session
182207
val user = Clerk.user
183208

184-
debugLog(TAG, "getSession - hasSession: ${session != null}, hasUser: ${user != null}")
185-
186209
val result = WritableNativeMap()
187210

188211
session?.let {
@@ -217,7 +240,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
217240
try {
218241
val prefs = reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
219242
val deviceToken = prefs.getString("DEVICE_TOKEN", null)
220-
debugLog(TAG, "getClientToken - deviceToken: ${if (deviceToken != null) "found" else "null"}")
221243
promise.resolve(deviceToken)
222244
} catch (e: Exception) {
223245
debugLog(TAG, "getClientToken failed: ${e.message}")
@@ -230,7 +252,8 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
230252
@ReactMethod
231253
override fun signOut(promise: Promise) {
232254
if (!Clerk.isInitialized.value) {
233-
promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.")
255+
// Resolve gracefully when not initialized (matches iOS behavior)
256+
promise.resolve(null)
234257
return
235258
}
236259

@@ -258,17 +281,13 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
258281
}
259282

260283
private fun handleAuthResult(resultCode: Int, data: Intent?) {
261-
debugLog(TAG, "handleAuthResult - resultCode: $resultCode")
262-
263284
val promise = pendingAuthPromise ?: return
264285
pendingAuthPromise = null
265286

266287
if (resultCode == Activity.RESULT_OK) {
267288
val session = Clerk.session
268289
val user = Clerk.user
269290

270-
debugLog(TAG, "handleAuthResult - hasSession: ${session != null}, hasUser: ${user != null}")
271-
272291
val result = WritableNativeMap()
273292

274293
// Top-level sessionId for JS SDK compatibility (matches iOS response format)
@@ -296,7 +315,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
296315

297316
promise.resolve(result)
298317
} else {
299-
debugLog(TAG, "handleAuthResult - user cancelled")
300318
val result = WritableNativeMap()
301319
result.putBoolean("cancelled", true)
302320
promise.resolve(result)

packages/expo/ios/ClerkExpoModule.swift

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public protocol ClerkViewFactoryProtocol {
2222
// SDK operations
2323
func configure(publishableKey: String, bearerToken: String?) async throws
2424
func getSession() async -> [String: Any]?
25+
func getClientToken() -> String?
2526
func signOut() async throws
2627
}
2728

@@ -31,9 +32,11 @@ public protocol ClerkViewFactoryProtocol {
3132
class ClerkExpoModule: RCTEventEmitter {
3233

3334
private static var _hasListeners = false
35+
private static weak var sharedInstance: ClerkExpoModule?
3436

3537
override init() {
3638
super.init()
39+
ClerkExpoModule.sharedInstance = self
3740
}
3841

3942
@objc override static func requiresMainQueueSetup() -> Bool {
@@ -52,6 +55,17 @@ class ClerkExpoModule: RCTEventEmitter {
5255
ClerkExpoModule._hasListeners = false
5356
}
5457

58+
/// Emits an onAuthStateChange event to JS from anywhere in the native layer.
59+
/// Used by inline views (AuthView, UserProfileView) to notify ClerkProvider
60+
/// of auth state changes in addition to the view-level onAuthEvent callback.
61+
static func emitAuthStateChange(type: String, sessionId: String?) {
62+
guard _hasListeners, let instance = sharedInstance else { return }
63+
instance.sendEvent(withName: "onAuthStateChange", body: [
64+
"type": type,
65+
"sessionId": sessionId as Any,
66+
])
67+
}
68+
5569
/// Returns the topmost presented view controller, avoiding deprecated `keyWindow`.
5670
private static func topViewController() -> UIViewController? {
5771
guard let scene = UIApplication.shared.connectedScenes
@@ -174,31 +188,12 @@ class ClerkExpoModule: RCTEventEmitter {
174188

175189
@objc func getClientToken(_ resolve: @escaping RCTPromiseResolveBlock,
176190
reject: @escaping RCTPromiseRejectBlock) {
177-
// Use a custom keychain service if configured in Info.plist (for extension apps
178-
// sharing a keychain group). Falls back to the main bundle identifier.
179-
let keychainService: String = {
180-
if let custom = Bundle.main.object(forInfoDictionaryKey: "ClerkKeychainService") as? String, !custom.isEmpty {
181-
return custom
182-
}
183-
return Bundle.main.bundleIdentifier ?? ""
184-
}()
185-
186-
let query: [String: Any] = [
187-
kSecClass as String: kSecClassGenericPassword,
188-
kSecAttrService as String: keychainService,
189-
kSecAttrAccount as String: "clerkDeviceToken",
190-
kSecReturnData as String: true,
191-
kSecMatchLimit as String: kSecMatchLimitOne
192-
]
193-
194-
var result: AnyObject?
195-
let status = SecItemCopyMatching(query as CFDictionary, &result)
196-
197-
if status == errSecSuccess, let data = result as? Data {
198-
resolve(String(data: data, encoding: .utf8))
199-
} else {
191+
guard let factory = clerkViewFactory else {
200192
resolve(nil)
193+
return
201194
}
195+
196+
resolve(factory.getClientToken())
202197
}
203198

204199
// MARK: - signOut
@@ -277,6 +272,12 @@ public class ClerkAuthNativeView: UIView {
277272
let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data()
278273
let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
279274
self?.onAuthEvent?(["type": eventName, "data": jsonString])
275+
276+
// Also emit module-level event so ClerkProvider's useNativeAuthEvents picks it up
277+
if eventName == "signInCompleted" || eventName == "signUpCompleted" {
278+
let sessionId = data["sessionId"] as? String
279+
ClerkExpoModule.emitAuthStateChange(type: "signedIn", sessionId: sessionId)
280+
}
280281
}
281282
) else { return }
282283

@@ -359,6 +360,12 @@ public class ClerkUserProfileNativeView: UIView {
359360
let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data()
360361
let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
361362
self?.onProfileEvent?(["type": eventName, "data": jsonString])
363+
364+
// Also emit module-level event for sign-out detection
365+
if eventName == "signedOut" {
366+
let sessionId = data["sessionId"] as? String
367+
ClerkExpoModule.emitAuthStateChange(type: "signedOut", sessionId: sessionId)
368+
}
362369
}
363370
) else { return }
364371

0 commit comments

Comments
 (0)