Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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/quiet-ravens-remember.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo': patch
---

Fix Expo native Clerk components and `useNativeSession()` staying stale when authentication changes between the JavaScript and native SDKs. JS-owned sign-in now hydrates native components on cold start, and sign-out from either JS or native updates the other side.
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ class ClerkExpoModule : Module() {
private var sharedInstance: ClerkExpoModule? = null

fun emitRefreshClient() {
sharedInstance?.sendEvent("refreshClient", emptyMap<String, Any?>())
val instance = sharedInstance ?: return
instance.sendEvent("refreshClient", instance.currentAuthStatePayload())
}
}

Expand Down Expand Up @@ -77,6 +78,10 @@ class ClerkExpoModule : Module() {
AsyncFunction("refreshClient") { promise: Promise ->
refreshClient(promise)
}

AsyncFunction("signOut") { sessionId: String?, promise: Promise ->
signOut(sessionId, promise)
}
}

private val reactContext: Context?
Expand All @@ -101,6 +106,44 @@ class ClerkExpoModule : Module() {
}
}

private fun currentAuthStatePayload(): Map<String, Any?> {
val session = Clerk.session
val user = Clerk.user
val result = mutableMapOf<String, Any?>(
"sessionId" to session?.id,
"clientToken" to try {
Clerk.getDeviceToken()
} catch (e: Exception) {
debugLog(TAG, "currentAuthStatePayload - getDeviceToken failed: ${e.message}")
null
}
)

result["session"] = session?.let {
mapOf(
"id" to it.id,
"status" to it.status.name,
"userId" to it.user?.id
)
}

result["user"] = user?.let {
val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId }
val primaryPhone = it.phoneNumbers.find { p -> p.id == it.primaryPhoneNumberId }

mapOf(
"id" to it.id,
"firstName" to it.firstName,
"lastName" to it.lastName,
"imageUrl" to it.imageUrl,
"primaryEmailAddress" to primaryEmail?.emailAddress,
"primaryPhoneNumber" to primaryPhone?.phoneNumber
)
}

return result
}

// MARK: - configure

private fun configure(pubKey: String, bearerToken: String?, promise: Promise) {
Expand Down Expand Up @@ -255,34 +298,7 @@ class ClerkExpoModule : Module() {
return
}

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

val result = mutableMapOf<String, Any?>()

session?.let {
result["session"] = mapOf(
"id" to it.id,
"status" to it.status.name,
"userId" to it.user?.id
)
}

user?.let {
val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId }
val primaryPhone = it.phoneNumbers.find { p -> p.id == it.primaryPhoneNumberId }

result["user"] = mapOf(
"id" to it.id,
"firstName" to it.firstName,
"lastName" to it.lastName,
"imageUrl" to it.imageUrl,
"primaryEmailAddress" to primaryEmail?.emailAddress,
"primaryPhoneNumber" to primaryPhone?.phoneNumber
)
}

promise.resolve(result)
promise.resolve(currentAuthStatePayload())
}

// MARK: - getClientToken
Expand Down Expand Up @@ -324,6 +340,33 @@ class ClerkExpoModule : Module() {
}
}

// MARK: - signOut

private fun signOut(sessionId: String?, promise: Promise) {
if (!Clerk.isInitialized.value) {
promise.resolve(null)
return
}

coroutineScope.launch {
try {
when (val result = Clerk.auth.signOut(sessionId = sessionId)) {
is ClerkResult.Failure -> promise.reject(
"E_SIGN_OUT_FAILED",
result.error?.firstMessage() ?: result.throwable?.message ?: "Sign-out failed",
null
)
is ClerkResult.Success -> {
emitRefreshClient()
promise.resolve(null)
}
}
} catch (e: Exception) {
promise.reject("E_SIGN_OUT_FAILED", e.message ?: "Sign-out failed", e)
}
}
}

// MARK: - Theme Loading

private fun loadThemeFromAssets(context: Context) {
Expand Down
4 changes: 4 additions & 0 deletions packages/expo/ios/ClerkExpoModule.m
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ @interface RCT_EXTERN_MODULE(ClerkExpo, RCTEventEmitter)
RCT_EXTERN_METHOD(refreshClient:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(signOut:(NSString *)sessionId
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)

@end
72 changes: 65 additions & 7 deletions packages/expo/ios/ClerkExpoModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ public enum ClerkNativeViewEvent: String {
}

// Global registry for the app-target native bridge (set by the app target at startup)
public var clerkNativeBridge: ClerkNativeBridgeProtocol?
public var clerkNativeBridge: ClerkNativeBridgeProtocol? {
didSet {
if clerkNativeBridge != nil {
emitClerkNativeBridgeReady()
}
}
}

// Protocol that the app target implements to provide Clerk SDK operations and SwiftUI views.
public protocol ClerkNativeBridgeProtocol {
Expand All @@ -25,8 +31,37 @@ public protocol ClerkNativeBridgeProtocol {
// SDK operations
func configure(publishableKey: String, bearerToken: String?) async throws
func getSession() async -> [String: Any]?
func getClientToken() -> String?
func getClientToken() async -> String?
func refreshClient() async throws
func signOut(sessionId: String?) async throws
}

public protocol ClerkNativeBridgeReadyObserver: AnyObject {
func clerkNativeBridgeDidBecomeReady()
}

private let clerkNativeBridgeReadyObservers = NSHashTable<AnyObject>.weakObjects()

public func addClerkNativeBridgeReadyObserver(_ observer: ClerkNativeBridgeReadyObserver) {
clerkNativeBridgeReadyObservers.add(observer)
}

public func removeClerkNativeBridgeReadyObserver(_ observer: ClerkNativeBridgeReadyObserver) {
clerkNativeBridgeReadyObservers.remove(observer)
}

public func emitClerkNativeBridgeReady() {
let notifyObservers = {
for observer in clerkNativeBridgeReadyObservers.allObjects {
(observer as? ClerkNativeBridgeReadyObserver)?.clerkNativeBridgeDidBecomeReady()
}
}

if Thread.isMainThread {
notifyObservers()
} else {
DispatchQueue.main.async(execute: notifyObservers)
}
}

// MARK: - Module
Expand Down Expand Up @@ -60,9 +95,9 @@ class ClerkExpoModule: RCTEventEmitter {

/// Emits a refreshClient event to JS from anywhere in the native layer.
/// Used by native views to ask ClerkProvider to reload JS client state.
static func emitRefreshClient() {
static func emitRefreshClient(_ body: [String: Any]? = nil) {
guard _hasListeners, let instance = sharedInstance else { return }
instance.sendEvent(withName: "refreshClient", body: nil)
instance.sendEvent(withName: "refreshClient", body: body ?? [:])
}

// MARK: - configure
Expand Down Expand Up @@ -110,7 +145,10 @@ class ClerkExpoModule: RCTEventEmitter {
return
}

resolve(bridge.getClientToken())
Task {
let token = await bridge.getClientToken()
resolve(token)
}
}

// MARK: - refreshClient
Expand All @@ -132,9 +170,29 @@ class ClerkExpoModule: RCTEventEmitter {
}
}

// MARK: - signOut

@objc func signOut(_ sessionId: String?,
resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock) {
guard let bridge = clerkNativeBridge else {
resolve(nil)
return
}

Task {
do {
try await bridge.signOut(sessionId: sessionId)
resolve(nil)
} catch {
reject("E_SIGN_OUT_FAILED", error.localizedDescription, error)
}
}
}

}

/// Requests that ClerkProvider reload the JS client from native client state.
public func emitClerkNativeRefreshClient() {
ClerkExpoModule.emitRefreshClient()
public func emitClerkNativeRefreshClient(_ body: [String: Any]? = nil) {
ClerkExpoModule.emitRefreshClient(body)
}
Loading
Loading