Skip to content

Commit c50e887

Browse files
authored
feat(expo): re-introduce two-way JS/native session sync (#8088)
1 parent 829583a commit c50e887

File tree

9 files changed

+605
-249
lines changed

9 files changed

+605
-249
lines changed
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+
Re-introduce two-way JS/native session sync for expo native components

packages/expo/android/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ ext {
1818
credentialsVersion = "1.3.0"
1919
googleIdVersion = "1.1.1"
2020
kotlinxCoroutinesVersion = "1.7.3"
21-
clerkAndroidApiVersion = "1.0.6"
22-
clerkAndroidUiVersion = "1.0.9"
21+
clerkAndroidApiVersion = "1.0.10"
22+
clerkAndroidUiVersion = "1.0.10"
2323
composeVersion = "1.7.0"
2424
activityComposeVersion = "1.9.0"
2525
lifecycleVersion = "2.8.0"

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

Lines changed: 67 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,70 @@ 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+
// If a bearer token was provided, wait for the session to hydrate
89+
// so callers that immediately call getSession() see the session.
90+
if (!bearerToken.isNullOrEmpty()) {
91+
withTimeout(5_000L) {
92+
Clerk.sessionFlow.first { it != null }
93+
}
94+
}
95+
} catch (e: TimeoutCancellationException) {
96+
val initError = Clerk.initializationError.value
97+
val message = if (initError != null) {
98+
"Clerk initialization timed out: ${initError.message}"
99+
} else {
100+
"Clerk initialization timed out after 10 seconds"
101+
}
102+
promise.reject("E_TIMEOUT", message)
103+
return@launch
86104
}
87-
} catch (e: TimeoutCancellationException) {
88-
val initError = Clerk.initializationError.value
89-
val message = if (initError != null) {
90-
"Clerk initialization timed out: ${initError.message}"
105+
106+
// Check for initialization errors
107+
val error = Clerk.initializationError.value
108+
if (error != null) {
109+
promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}")
91110
} else {
92-
"Clerk initialization timed out after 10 seconds"
111+
promise.resolve(null)
93112
}
94-
promise.reject("E_TIMEOUT", message)
95113
return@launch
96114
}
97115

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)
116+
// Already initialized — use the public SDK API to update
117+
// the device token and trigger a client/environment refresh.
118+
if (!bearerToken.isNullOrEmpty()) {
119+
val result = Clerk.updateDeviceToken(bearerToken)
120+
if (result is ClerkResult.Failure) {
121+
debugLog(TAG, "configure - updateDeviceToken failed: ${result.error}")
122+
}
123+
124+
// Wait for session to appear with the new token (up to 5s)
125+
try {
126+
withTimeout(5_000L) {
127+
Clerk.sessionFlow.first { it != null }
128+
}
129+
} catch (_: TimeoutCancellationException) {
130+
debugLog(TAG, "configure - session did not appear after token update")
131+
}
104132
}
133+
134+
promise.resolve(null)
105135
} catch (e: Exception) {
106136
promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${e.message}", e)
107137
}
@@ -174,15 +204,15 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
174204
@ReactMethod
175205
override fun getSession(promise: Promise) {
176206
if (!Clerk.isInitialized.value) {
177-
promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.")
207+
// Return null when not initialized (matches iOS behavior)
208+
// so callers can proceed to call configure() with a bearer token.
209+
promise.resolve(null)
178210
return
179211
}
180212

181213
val session = Clerk.session
182214
val user = Clerk.user
183215

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

188218
session?.let {
@@ -217,7 +247,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
217247
try {
218248
val prefs = reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
219249
val deviceToken = prefs.getString("DEVICE_TOKEN", null)
220-
debugLog(TAG, "getClientToken - deviceToken: ${if (deviceToken != null) "found" else "null"}")
221250
promise.resolve(deviceToken)
222251
} catch (e: Exception) {
223252
debugLog(TAG, "getClientToken failed: ${e.message}")
@@ -230,7 +259,13 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
230259
@ReactMethod
231260
override fun signOut(promise: Promise) {
232261
if (!Clerk.isInitialized.value) {
233-
promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.")
262+
// Clear DEVICE_TOKEN from SharedPreferences even when not initialized,
263+
// so the next Clerk.initialize() doesn't boot with a stale client token.
264+
reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
265+
.edit()
266+
.remove("DEVICE_TOKEN")
267+
.apply()
268+
promise.resolve(null)
234269
return
235270
}
236271

@@ -258,17 +293,13 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
258293
}
259294

260295
private fun handleAuthResult(resultCode: Int, data: Intent?) {
261-
debugLog(TAG, "handleAuthResult - resultCode: $resultCode")
262-
263296
val promise = pendingAuthPromise ?: return
264297
pendingAuthPromise = null
265298

266299
if (resultCode == Activity.RESULT_OK) {
267300
val session = Clerk.session
268301
val user = Clerk.user
269302

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

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

297328
promise.resolve(result)
298329
} else {
299-
debugLog(TAG, "handleAuthResult - user cancelled")
300330
val result = WritableNativeMap()
301331
result.putBoolean("cancelled", true)
302332
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)