Skip to content
Merged
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/tidy-expo-ios-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/expo": minor
---

Fixes iOS development builds across Expo SDK versions by linking the Clerk iOS SDK through React Native's Swift Package Manager podspec support. This raises the minimum supported React Native version to 0.75, where that podspec SPM support is available; `@clerk/expo` already supports Expo SDK 53 and newer, and Expo SDK 53 ships with React Native 0.79.
384 changes: 5 additions & 379 deletions packages/expo/app.plugin.js

Large diffs are not rendered by default.

4 changes: 1 addition & 3 deletions packages/expo/ios/ClerkAuthNativeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,7 @@ public class ClerkAuthNativeView: ClerkNativeViewHost {
}

override func makeHostedController() -> UIViewController? {
guard let bridge = clerkNativeBridge else { return nil }

return bridge.makeAuthViewController(
return ClerkNativeBridge.shared.makeAuthViewController(
mode: currentMode,
dismissible: currentDismissible,
onEvent: { [weak self] event, _ in
Expand Down
20 changes: 16 additions & 4 deletions packages/expo/ios/ClerkExpo.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ else
}
end

clerk_ios_repo = 'https://github.com/clerk/clerk-ios.git'
clerk_ios_version = '1.2.4'

Pod::Spec.new do |s|
s.name = 'ClerkExpo'
s.version = package['version']
Expand All @@ -35,10 +38,19 @@ Pod::Spec.new do |s|
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}

# Only include the module files in the pod (both Swift and ObjC bridges).
# ClerkNativeBridge.swift is injected into the app target by the config plugin
# because it uses `import ClerkKit` which is only available via SPM in the app target.
s.source_files = "ClerkExpoModule.swift", "ClerkExpoModule.m",
if defined?(spm_dependency)
spm_dependency(
s,
url: clerk_ios_repo,
requirement: { :kind => 'exactVersion', :version => clerk_ios_version },
products: ['ClerkKit', 'ClerkKitUI']
)
else
raise 'ClerkExpo requires React Native 0.75 or newer for iOS Swift Package Manager dependencies.'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep RN 0.73/0.74 pod installs working

For apps still on React Native 0.73/0.74 (which satisfied @clerk/expo's previous peer range and can receive this patch via a caret range), spm_dependency is not defined by RN's CocoaPods scripts, so this raise makes every iOS pod install fail even though those apps built with the old Xcode-project injection path. Because this is being released as a patch, please keep a fallback for those RN versions or avoid shipping this path to them.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our supported expo range is >=53, so supporting 0.73 and 0.74 is already a mismatch. Expo 53 contains React Native 0.79

end

s.source_files = "ClerkNativeBridge.swift",
"ClerkExpoModule.swift", "ClerkExpoModule.m",
"ClerkNativeViewHost.swift",
"ClerkAuthNativeView.swift",
"ClerkAuthViewManager.m",
Expand Down
88 changes: 7 additions & 81 deletions packages/expo/ios/ClerkExpoModule.swift
Original file line number Diff line number Diff line change
@@ -1,67 +1,10 @@
// ClerkExpoModule - Native module for Clerk integration
// This module provides the configure function, client sync, and native view bridges.
// SwiftUI Clerk views are created by the app target through ClerkNativeBridge because
// the Clerk SDK (SPM) isn't accessible from the CocoaPods-backed React Native pod.
// SwiftUI Clerk views are created by ClerkNativeBridge through the Clerk iOS SPM dependency.

import UIKit
import React

/// Events emitted by the native view wrappers to their React Native host views.
public enum ClerkNativeViewEvent: String {
/// Emitted by the Expo host view when app-owned dismissible content leaves the window.
case dismissed
}

// Global registry for the app-target native bridge (set by the app target at startup)
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 {
// Inline rendering — returns UIViewController to preserve SwiftUI lifecycle
func makeAuthViewController(mode: String, dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void) -> UIViewController?
func makeUserProfileViewController(dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void) -> UIViewController?
func makeUserButtonViewController() -> UIViewController?

// SDK operations
func configure(publishableKey: String, bearerToken: String?) async throws
func getClientToken() async -> String?
func syncFromJsClientToken(_ clientToken: String?, sourceId: String?, shouldRefreshClient: Bool) 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

@objc(ClerkExpo)
Expand All @@ -73,6 +16,9 @@ class ClerkExpoModule: RCTEventEmitter {
override init() {
super.init()
ClerkExpoModule.sharedInstance = self
ClerkNativeBridge.setClientChangedEmitter { body in
Self.emitClientChanged(body)
}
}

@objc override static func requiresMainQueueSetup() -> Bool {
Expand Down Expand Up @@ -120,14 +66,9 @@ class ClerkExpoModule: RCTEventEmitter {
bearerToken: String?,
resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock) {
guard let bridge = clerkNativeBridge else {
reject("E_NOT_INITIALIZED", "Clerk not initialized. Make sure ClerkNativeBridge is registered.", nil)
return
}

Task {
do {
try await bridge.configure(publishableKey: publishableKey, bearerToken: bearerToken)
try await ClerkNativeBridge.shared.configure(publishableKey: publishableKey, bearerToken: bearerToken)
resolve(nil)
} catch {
reject("E_CONFIGURE_FAILED", error.localizedDescription, error)
Expand All @@ -139,13 +80,8 @@ class ClerkExpoModule: RCTEventEmitter {

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

Task {
let token = await bridge.getClientToken()
let token = await ClerkNativeBridge.shared.getClientToken()
resolve(token)
}
}
Expand All @@ -157,18 +93,13 @@ class ClerkExpoModule: RCTEventEmitter {
shouldRefreshClient: Any?,
resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock) {
guard let bridge = clerkNativeBridge else {
resolve(nil)
return
}

let normalizedClientToken = clientToken as? String
let normalizedSourceId = sourceId as? String
let defaultShouldRefreshClient = normalizedClientToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
let normalizedShouldRefreshClient = (shouldRefreshClient as? Bool) ?? defaultShouldRefreshClient
Task {
do {
try await bridge.syncFromJsClientToken(
try await ClerkNativeBridge.shared.syncFromJsClientToken(
normalizedClientToken,
sourceId: normalizedSourceId,
shouldRefreshClient: normalizedShouldRefreshClient
Expand All @@ -181,8 +112,3 @@ class ClerkExpoModule: RCTEventEmitter {
}

}

/// Requests that ClerkProvider reload the JS client from native client state.
public func emitClerkNativeClientChanged(_ body: [String: Any]? = nil) {
ClerkExpoModule.emitClientChanged(body)
}
65 changes: 46 additions & 19 deletions packages/expo/ios/ClerkNativeBridge.swift
Original file line number Diff line number Diff line change
@@ -1,27 +1,40 @@
// ClerkNativeBridge - Provides app-target Clerk SDK operations and SwiftUI view controllers to ClerkExpo.
// This file is injected into the app target by the config plugin.
// It uses the ClerkKit Swift package, which is only accessible from the app target.
// ClerkNativeBridge - Provides Clerk SDK operations and SwiftUI view controllers to ClerkExpo.

import UIKit
import SwiftUI
import Observation
@_spi(FrameworkIntegration) import ClerkKit
import ClerkKitUI
import ClerkExpo // Import the pod to access ClerkNativeBridgeProtocol

/// Events emitted by the native view wrappers to their React Native host views.
public enum ClerkNativeViewEvent: String {
/// Emitted by the Expo host view when app-owned dismissible content leaves the window.
case dismissed
}

extension Notification.Name {
static let clerkNativeSDKDidConfigure = Notification.Name("com.clerk.expo.native-sdk.did-configure")
}

private let clerkNativeClientEventQueue = DispatchQueue(label: "com.clerk.expo.native-client-events")
private var clerkNativeClientChangedEmitter: (([String: Any]?) -> Void)?

private struct ClerkExpoHeaderMiddleware: ClerkRequestMiddleware {
// Replaced by the config plugin when this bridge is copied into the app target.
private static let hostSdkVersion = "__CLERK_EXPO_VERSION__"
private static var hostSdkVersion: String? {
Bundle.main.object(forInfoDictionaryKey: "ClerkExpoVersion") as? String
}

func prepare(_ request: inout URLRequest) async throws {
request.addValue("expo", forHTTPHeaderField: "x-clerk-host-sdk")
request.addValue(Self.hostSdkVersion, forHTTPHeaderField: "x-clerk-host-sdk-version")
if let hostSdkVersion = Self.hostSdkVersion, !hostSdkVersion.isEmpty {
request.addValue(hostSdkVersion, forHTTPHeaderField: "x-clerk-host-sdk-version")
}
}
}

// MARK: - Native Bridge Implementation

final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
final class ClerkNativeBridge {
static let shared = ClerkNativeBridge()

private static let clerkLoadMaxAttempts = 30
Expand Down Expand Up @@ -52,14 +65,10 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
return Bundle.main.bundleIdentifier
}

// Register this app-target bridge with the ClerkExpo module.
@MainActor static func register() {
shared.loadThemes()
clerkNativeBridge = shared
}

@MainActor
func configure(publishableKey: String, bearerToken: String? = nil) async throws {
loadThemes()

if Self.shouldReconfigure(for: publishableKey) {
try await Clerk.reconfigure(publishableKey: publishableKey, options: Self.makeClerkOptions())
Self.clerkConfigured = true
Expand All @@ -69,7 +78,7 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken)
await Self.waitForLoadedClientIfNeeded(shouldWaitForClient)
Self.emitClientChangedIfReceivedToken(bearerToken)
emitClerkNativeBridgeReady()
Self.postConfiguredNotification()
return
}

Expand All @@ -89,13 +98,13 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken)
await Self.waitForLoadedClientIfNeeded(shouldWaitForClient)
Self.emitClientChangedIfReceivedToken(bearerToken)
emitClerkNativeBridgeReady()
Self.postConfiguredNotification()
}

@MainActor
private static func emitClientChangedIfReceivedToken(_ bearerToken: String?) {
guard let token = bearerToken, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
emitClerkNativeClientChanged(Self.clientChangedPayload())
Self.emitClientChanged(Self.clientChangedPayload())
}

@MainActor
Expand Down Expand Up @@ -124,7 +133,7 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
if newClientState != self.lastObservedClientState {
self.lastObservedClientState = newClientState
let payload = Self.clientChangedPayload()
emitClerkNativeClientChanged(payload)
Self.emitClientChanged(payload)
}

self.observeClient(generation: generation)
Expand Down Expand Up @@ -263,7 +272,25 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
}

lastObservedClientState = Self.clientStateSnapshot()
emitClerkNativeClientChanged(Self.clientChangedPayload(sourceId: sourceId))
Self.emitClientChanged(Self.clientChangedPayload(sourceId: sourceId))
}

private static func postConfiguredNotification() {
NotificationCenter.default.post(name: .clerkNativeSDKDidConfigure, object: nil)
}

static func setClientChangedEmitter(_ emitter: (([String: Any]?) -> Void)?) {
clerkNativeClientEventQueue.sync {
clerkNativeClientChangedEmitter = emitter
}
}

/// Requests that ClerkProvider reload the JS client from native client state.
static func emitClientChanged(_ body: [String: Any]? = nil) {
let emitter = clerkNativeClientEventQueue.sync {
clerkNativeClientChangedEmitter
}
emitter?(body)
}

private static func authMode(from mode: String) -> AuthView.Mode {
Expand Down
29 changes: 24 additions & 5 deletions packages/expo/ios/ClerkNativeViewHost.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import UIKit

public class ClerkNativeViewHost: UIView, ClerkNativeBridgeReadyObserver {
public class ClerkNativeViewHost: UIView {
private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self)
private var hasInitialized: Bool = false
private var configuredObserver: NSObjectProtocol?

override public init(frame: CGRect) {
super.init(frame: frame)
Expand All @@ -12,22 +13,26 @@ public class ClerkNativeViewHost: UIView, ClerkNativeBridgeReadyObserver {
fatalError("init(coder:) has not been implemented")
}

deinit {
removeConfiguredObserver()
}

override public func didMoveToWindow() {
super.didMoveToWindow()

guard window != nil else {
if hasInitialized {
hostedViewDidDetachFromWindow()
}
removeClerkNativeBridgeReadyObserver(self)
removeConfiguredObserver()
hostingCoordinator.detach()
hasInitialized = false
return
}

guard !hasInitialized else { return }
hasInitialized = true
addClerkNativeBridgeReadyObserver(self)
addConfiguredObserver()
hostedViewDidAttachToWindow()
updateHostedView()
}
Expand All @@ -51,8 +56,22 @@ public class ClerkNativeViewHost: UIView, ClerkNativeBridgeReadyObserver {

func hostedViewDidDetachFromWindow() {}

public func clerkNativeBridgeDidBecomeReady() {
setNeedsHostedViewUpdate()
private func addConfiguredObserver() {
guard configuredObserver == nil else { return }

configuredObserver = NotificationCenter.default.addObserver(
forName: .clerkNativeSDKDidConfigure,
object: nil,
queue: .main
) { [weak self] _ in
self?.setNeedsHostedViewUpdate()
}
}

private func removeConfiguredObserver() {
guard let configuredObserver else { return }
NotificationCenter.default.removeObserver(configuredObserver)
self.configuredObserver = nil
}

private func updateHostedView() {
Expand Down
4 changes: 1 addition & 3 deletions packages/expo/ios/ClerkUserButtonNativeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import UIKit

public class ClerkUserButtonNativeView: ClerkNativeViewHost {
override func makeHostedController() -> UIViewController? {
guard let bridge = clerkNativeBridge else { return nil }

return bridge.makeUserButtonViewController()
return ClerkNativeBridge.shared.makeUserButtonViewController()
}
}

Expand Down
Loading
Loading