Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/native-session-sync-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/expo": patch
---

Re-introduce two-way JS/native session sync for expo native components
4 changes: 2 additions & 2 deletions packages/expo/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ ext {
credentialsVersion = "1.3.0"
googleIdVersion = "1.1.1"
kotlinxCoroutinesVersion = "1.7.3"
clerkAndroidApiVersion = "1.0.6"
clerkAndroidUiVersion = "1.0.9"
clerkAndroidApiVersion = "1.0.10"
clerkAndroidUiVersion = "1.0.10"
composeVersion = "1.7.0"
activityComposeVersion = "1.9.0"
lifecycleVersion = "2.8.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import android.util.Log
import com.clerk.api.Clerk
import com.clerk.api.network.serialization.ClerkResult
import com.facebook.react.bridge.ActivityEventListener
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
Expand Down Expand Up @@ -67,41 +68,70 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
try {
publishableKey = pubKey

// If the JS SDK has a bearer token, write it to the native SDK's
// SharedPreferences so both SDKs share the same Clerk API client.
if (!bearerToken.isNullOrEmpty()) {
reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
.edit()
.putString("DEVICE_TOKEN", bearerToken)
.apply()
debugLog(TAG, "configure - wrote JS bearer token to native SharedPreferences")
}

Clerk.initialize(reactApplicationContext, pubKey)
if (!Clerk.isInitialized.value) {
// First-time initialization — write the bearer token to SharedPreferences
// before initializing so the SDK boots with the correct client.
if (!bearerToken.isNullOrEmpty()) {
reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
.edit()
.putString("DEVICE_TOKEN", bearerToken)
.apply()
}

// Wait for initialization to complete with timeout
try {
withTimeout(10_000L) {
Clerk.isInitialized.first { it }
Clerk.initialize(reactApplicationContext, pubKey)

// Wait for initialization to complete with timeout
try {
withTimeout(10_000L) {
Clerk.isInitialized.first { it }
}
// If a bearer token was provided, wait for the session to hydrate
// so callers that immediately call getSession() see the session.
if (!bearerToken.isNullOrEmpty()) {
withTimeout(5_000L) {
Clerk.sessionFlow.first { it != null }
}
}
} catch (e: TimeoutCancellationException) {
val initError = Clerk.initializationError.value
val message = if (initError != null) {
"Clerk initialization timed out: ${initError.message}"
} else {
"Clerk initialization timed out after 10 seconds"
}
promise.reject("E_TIMEOUT", message)
return@launch
}
} catch (e: TimeoutCancellationException) {
val initError = Clerk.initializationError.value
val message = if (initError != null) {
"Clerk initialization timed out: ${initError.message}"

// Check for initialization errors
val error = Clerk.initializationError.value
if (error != null) {
promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}")
} else {
"Clerk initialization timed out after 10 seconds"
promise.resolve(null)
}
promise.reject("E_TIMEOUT", message)
return@launch
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Check for initialization errors
val error = Clerk.initializationError.value
if (error != null) {
promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}")
} else {
promise.resolve(null)
// Already initialized — use the public SDK API to update
// the device token and trigger a client/environment refresh.
if (!bearerToken.isNullOrEmpty()) {
val result = Clerk.updateDeviceToken(bearerToken)
if (result is ClerkResult.Failure) {
debugLog(TAG, "configure - updateDeviceToken failed: ${result.error}")
}

// Wait for session to appear with the new token (up to 5s)
try {
withTimeout(5_000L) {
Clerk.sessionFlow.first { it != null }
}
} catch (_: TimeoutCancellationException) {
debugLog(TAG, "configure - session did not appear after token update")
}
}

promise.resolve(null)
} catch (e: Exception) {
promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${e.message}", e)
}
Expand Down Expand Up @@ -174,15 +204,15 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
@ReactMethod
override fun getSession(promise: Promise) {
if (!Clerk.isInitialized.value) {
promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.")
// Return null when not initialized (matches iOS behavior)
// so callers can proceed to call configure() with a bearer token.
promise.resolve(null)
return
}

val session = Clerk.session
val user = Clerk.user

debugLog(TAG, "getSession - hasSession: ${session != null}, hasUser: ${user != null}")

val result = WritableNativeMap()

session?.let {
Expand Down Expand Up @@ -217,7 +247,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
try {
val prefs = reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
val deviceToken = prefs.getString("DEVICE_TOKEN", null)
debugLog(TAG, "getClientToken - deviceToken: ${if (deviceToken != null) "found" else "null"}")
promise.resolve(deviceToken)
} catch (e: Exception) {
debugLog(TAG, "getClientToken failed: ${e.message}")
Expand All @@ -230,7 +259,13 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
@ReactMethod
override fun signOut(promise: Promise) {
if (!Clerk.isInitialized.value) {
promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.")
// Clear DEVICE_TOKEN from SharedPreferences even when not initialized,
// so the next Clerk.initialize() doesn't boot with a stale client token.
reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
.edit()
.remove("DEVICE_TOKEN")
.apply()
promise.resolve(null)
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Expand Down Expand Up @@ -258,17 +293,13 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
}

private fun handleAuthResult(resultCode: Int, data: Intent?) {
debugLog(TAG, "handleAuthResult - resultCode: $resultCode")

val promise = pendingAuthPromise ?: return
pendingAuthPromise = null

if (resultCode == Activity.RESULT_OK) {
val session = Clerk.session
val user = Clerk.user

debugLog(TAG, "handleAuthResult - hasSession: ${session != null}, hasUser: ${user != null}")

val result = WritableNativeMap()

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

promise.resolve(result)
} else {
debugLog(TAG, "handleAuthResult - user cancelled")
val result = WritableNativeMap()
result.putBoolean("cancelled", true)
promise.resolve(result)
Expand Down
53 changes: 30 additions & 23 deletions packages/expo/ios/ClerkExpoModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public protocol ClerkViewFactoryProtocol {
// SDK operations
func configure(publishableKey: String, bearerToken: String?) async throws
func getSession() async -> [String: Any]?
func getClientToken() -> String?
func signOut() async throws
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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

private static var _hasListeners = false
private static weak var sharedInstance: ClerkExpoModule?

override init() {
super.init()
ClerkExpoModule.sharedInstance = self
}

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

/// Emits an onAuthStateChange event to JS from anywhere in the native layer.
/// Used by inline views (AuthView, UserProfileView) to notify ClerkProvider
/// of auth state changes in addition to the view-level onAuthEvent callback.
static func emitAuthStateChange(type: String, sessionId: String?) {
guard _hasListeners, let instance = sharedInstance else { return }
instance.sendEvent(withName: "onAuthStateChange", body: [
"type": type,
"sessionId": sessionId as Any,
])
}

/// Returns the topmost presented view controller, avoiding deprecated `keyWindow`.
private static func topViewController() -> UIViewController? {
guard let scene = UIApplication.shared.connectedScenes
Expand Down Expand Up @@ -174,31 +188,12 @@ class ClerkExpoModule: RCTEventEmitter {

@objc func getClientToken(_ resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock) {
// Use a custom keychain service if configured in Info.plist (for extension apps
// sharing a keychain group). Falls back to the main bundle identifier.
let keychainService: String = {
if let custom = Bundle.main.object(forInfoDictionaryKey: "ClerkKeychainService") as? String, !custom.isEmpty {
return custom
}
return Bundle.main.bundleIdentifier ?? ""
}()

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: "clerkDeviceToken",
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]

var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)

if status == errSecSuccess, let data = result as? Data {
resolve(String(data: data, encoding: .utf8))
} else {
guard let factory = clerkViewFactory else {
resolve(nil)
return
}

resolve(factory.getClientToken())
}

// MARK: - signOut
Expand Down Expand Up @@ -277,6 +272,12 @@ public class ClerkAuthNativeView: UIView {
let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data()
let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
self?.onAuthEvent?(["type": eventName, "data": jsonString])

// Also emit module-level event so ClerkProvider's useNativeAuthEvents picks it up
if eventName == "signInCompleted" || eventName == "signUpCompleted" {
let sessionId = data["sessionId"] as? String
ClerkExpoModule.emitAuthStateChange(type: "signedIn", sessionId: sessionId)
}
}
) else { return }

Expand Down Expand Up @@ -359,6 +360,12 @@ public class ClerkUserProfileNativeView: UIView {
let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data()
let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
self?.onProfileEvent?(["type": eventName, "data": jsonString])

// Also emit module-level event for sign-out detection
if eventName == "signedOut" {
let sessionId = data["sessionId"] as? String
ClerkExpoModule.emitAuthStateChange(type: "signedOut", sessionId: sessionId)
}
}
) else { return }

Expand Down
Loading
Loading