Skip to content

Commit bfe280a

Browse files
fix(expo): link iOS SDK through podspec SPM dependencies (#8927)
Co-authored-by: Robert Soriano <sorianorobertc@gmail.com>
1 parent 88525a6 commit bfe280a

11 files changed

Lines changed: 107 additions & 518 deletions

.changeset/tidy-expo-ios-import.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/expo": minor
3+
---
4+
5+
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.

packages/expo/app.plugin.js

Lines changed: 5 additions & 379 deletions
Large diffs are not rendered by default.

packages/expo/ios/ClerkAuthNativeView.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,7 @@ public class ClerkAuthNativeView: ClerkNativeViewHost {
4646
}
4747

4848
override func makeHostedController() -> UIViewController? {
49-
guard let bridge = clerkNativeBridge else { return nil }
50-
51-
return bridge.makeAuthViewController(
49+
return ClerkNativeBridge.shared.makeAuthViewController(
5250
mode: currentMode,
5351
dismissible: currentDismissible,
5452
onEvent: { [weak self] event, _ in

packages/expo/ios/ClerkExpo.podspec

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ else
1717
}
1818
end
1919

20+
clerk_ios_repo = 'https://github.com/clerk/clerk-ios.git'
21+
clerk_ios_version = '1.2.4'
22+
2023
Pod::Spec.new do |s|
2124
s.name = 'ClerkExpo'
2225
s.version = package['version']
@@ -35,10 +38,19 @@ Pod::Spec.new do |s|
3538
'SWIFT_COMPILATION_MODE' => 'wholemodule'
3639
}
3740

38-
# Only include the module files in the pod (both Swift and ObjC bridges).
39-
# ClerkNativeBridge.swift is injected into the app target by the config plugin
40-
# because it uses `import ClerkKit` which is only available via SPM in the app target.
41-
s.source_files = "ClerkExpoModule.swift", "ClerkExpoModule.m",
41+
if defined?(spm_dependency)
42+
spm_dependency(
43+
s,
44+
url: clerk_ios_repo,
45+
requirement: { :kind => 'exactVersion', :version => clerk_ios_version },
46+
products: ['ClerkKit', 'ClerkKitUI']
47+
)
48+
else
49+
raise 'ClerkExpo requires React Native 0.75 or newer for iOS Swift Package Manager dependencies.'
50+
end
51+
52+
s.source_files = "ClerkNativeBridge.swift",
53+
"ClerkExpoModule.swift", "ClerkExpoModule.m",
4254
"ClerkNativeViewHost.swift",
4355
"ClerkAuthNativeView.swift",
4456
"ClerkAuthViewManager.m",
Lines changed: 7 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,10 @@
11
// ClerkExpoModule - Native module for Clerk integration
22
// This module provides the configure function, client sync, and native view bridges.
3-
// SwiftUI Clerk views are created by the app target through ClerkNativeBridge because
4-
// the Clerk SDK (SPM) isn't accessible from the CocoaPods-backed React Native pod.
3+
// SwiftUI Clerk views are created by ClerkNativeBridge through the Clerk iOS SPM dependency.
54

65
import UIKit
76
import React
87

9-
/// Events emitted by the native view wrappers to their React Native host views.
10-
public enum ClerkNativeViewEvent: String {
11-
/// Emitted by the Expo host view when app-owned dismissible content leaves the window.
12-
case dismissed
13-
}
14-
15-
// Global registry for the app-target native bridge (set by the app target at startup)
16-
public var clerkNativeBridge: ClerkNativeBridgeProtocol? {
17-
didSet {
18-
if clerkNativeBridge != nil {
19-
emitClerkNativeBridgeReady()
20-
}
21-
}
22-
}
23-
24-
// Protocol that the app target implements to provide Clerk SDK operations and SwiftUI views.
25-
public protocol ClerkNativeBridgeProtocol {
26-
// Inline rendering — returns UIViewController to preserve SwiftUI lifecycle
27-
func makeAuthViewController(mode: String, dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void) -> UIViewController?
28-
func makeUserProfileViewController(dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void) -> UIViewController?
29-
func makeUserButtonViewController() -> UIViewController?
30-
31-
// SDK operations
32-
func configure(publishableKey: String, bearerToken: String?) async throws
33-
func getClientToken() async -> String?
34-
func syncFromJsClientToken(_ clientToken: String?, sourceId: String?, shouldRefreshClient: Bool) async throws
35-
}
36-
37-
public protocol ClerkNativeBridgeReadyObserver: AnyObject {
38-
func clerkNativeBridgeDidBecomeReady()
39-
}
40-
41-
private let clerkNativeBridgeReadyObservers = NSHashTable<AnyObject>.weakObjects()
42-
43-
public func addClerkNativeBridgeReadyObserver(_ observer: ClerkNativeBridgeReadyObserver) {
44-
clerkNativeBridgeReadyObservers.add(observer)
45-
}
46-
47-
public func removeClerkNativeBridgeReadyObserver(_ observer: ClerkNativeBridgeReadyObserver) {
48-
clerkNativeBridgeReadyObservers.remove(observer)
49-
}
50-
51-
public func emitClerkNativeBridgeReady() {
52-
let notifyObservers = {
53-
for observer in clerkNativeBridgeReadyObservers.allObjects {
54-
(observer as? ClerkNativeBridgeReadyObserver)?.clerkNativeBridgeDidBecomeReady()
55-
}
56-
}
57-
58-
if Thread.isMainThread {
59-
notifyObservers()
60-
} else {
61-
DispatchQueue.main.async(execute: notifyObservers)
62-
}
63-
}
64-
658
// MARK: - Module
669

6710
@objc(ClerkExpo)
@@ -73,6 +16,9 @@ class ClerkExpoModule: RCTEventEmitter {
7316
override init() {
7417
super.init()
7518
ClerkExpoModule.sharedInstance = self
19+
ClerkNativeBridge.setClientChangedEmitter { body in
20+
Self.emitClientChanged(body)
21+
}
7622
}
7723

7824
@objc override static func requiresMainQueueSetup() -> Bool {
@@ -120,14 +66,9 @@ class ClerkExpoModule: RCTEventEmitter {
12066
bearerToken: String?,
12167
resolve: @escaping RCTPromiseResolveBlock,
12268
reject: @escaping RCTPromiseRejectBlock) {
123-
guard let bridge = clerkNativeBridge else {
124-
reject("E_NOT_INITIALIZED", "Clerk not initialized. Make sure ClerkNativeBridge is registered.", nil)
125-
return
126-
}
127-
12869
Task {
12970
do {
130-
try await bridge.configure(publishableKey: publishableKey, bearerToken: bearerToken)
71+
try await ClerkNativeBridge.shared.configure(publishableKey: publishableKey, bearerToken: bearerToken)
13172
resolve(nil)
13273
} catch {
13374
reject("E_CONFIGURE_FAILED", error.localizedDescription, error)
@@ -139,13 +80,8 @@ class ClerkExpoModule: RCTEventEmitter {
13980

14081
@objc func getClientToken(_ resolve: @escaping RCTPromiseResolveBlock,
14182
reject: @escaping RCTPromiseRejectBlock) {
142-
guard let bridge = clerkNativeBridge else {
143-
resolve(nil)
144-
return
145-
}
146-
14783
Task {
148-
let token = await bridge.getClientToken()
84+
let token = await ClerkNativeBridge.shared.getClientToken()
14985
resolve(token)
15086
}
15187
}
@@ -157,18 +93,13 @@ class ClerkExpoModule: RCTEventEmitter {
15793
shouldRefreshClient: Any?,
15894
resolve: @escaping RCTPromiseResolveBlock,
15995
reject: @escaping RCTPromiseRejectBlock) {
160-
guard let bridge = clerkNativeBridge else {
161-
resolve(nil)
162-
return
163-
}
164-
16596
let normalizedClientToken = clientToken as? String
16697
let normalizedSourceId = sourceId as? String
16798
let defaultShouldRefreshClient = normalizedClientToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
16899
let normalizedShouldRefreshClient = (shouldRefreshClient as? Bool) ?? defaultShouldRefreshClient
169100
Task {
170101
do {
171-
try await bridge.syncFromJsClientToken(
102+
try await ClerkNativeBridge.shared.syncFromJsClientToken(
172103
normalizedClientToken,
173104
sourceId: normalizedSourceId,
174105
shouldRefreshClient: normalizedShouldRefreshClient
@@ -181,8 +112,3 @@ class ClerkExpoModule: RCTEventEmitter {
181112
}
182113

183114
}
184-
185-
/// Requests that ClerkProvider reload the JS client from native client state.
186-
public func emitClerkNativeClientChanged(_ body: [String: Any]? = nil) {
187-
ClerkExpoModule.emitClientChanged(body)
188-
}

packages/expo/ios/ClerkNativeBridge.swift

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,40 @@
1-
// ClerkNativeBridge - Provides app-target Clerk SDK operations and SwiftUI view controllers to ClerkExpo.
2-
// This file is injected into the app target by the config plugin.
3-
// It uses the ClerkKit Swift package, which is only accessible from the app target.
1+
// ClerkNativeBridge - Provides Clerk SDK operations and SwiftUI view controllers to ClerkExpo.
42

53
import UIKit
64
import SwiftUI
75
import Observation
86
@_spi(FrameworkIntegration) import ClerkKit
97
import ClerkKitUI
10-
import ClerkExpo // Import the pod to access ClerkNativeBridgeProtocol
8+
9+
/// Events emitted by the native view wrappers to their React Native host views.
10+
public enum ClerkNativeViewEvent: String {
11+
/// Emitted by the Expo host view when app-owned dismissible content leaves the window.
12+
case dismissed
13+
}
14+
15+
extension Notification.Name {
16+
static let clerkNativeSDKDidConfigure = Notification.Name("com.clerk.expo.native-sdk.did-configure")
17+
}
18+
19+
private let clerkNativeClientEventQueue = DispatchQueue(label: "com.clerk.expo.native-client-events")
20+
private var clerkNativeClientChangedEmitter: (([String: Any]?) -> Void)?
1121

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

1627
func prepare(_ request: inout URLRequest) async throws {
1728
request.addValue("expo", forHTTPHeaderField: "x-clerk-host-sdk")
18-
request.addValue(Self.hostSdkVersion, forHTTPHeaderField: "x-clerk-host-sdk-version")
29+
if let hostSdkVersion = Self.hostSdkVersion, !hostSdkVersion.isEmpty {
30+
request.addValue(hostSdkVersion, forHTTPHeaderField: "x-clerk-host-sdk-version")
31+
}
1932
}
2033
}
2134

2235
// MARK: - Native Bridge Implementation
2336

24-
final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
37+
final class ClerkNativeBridge {
2538
static let shared = ClerkNativeBridge()
2639

2740
private static let clerkLoadMaxAttempts = 30
@@ -52,14 +65,10 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
5265
return Bundle.main.bundleIdentifier
5366
}
5467

55-
// Register this app-target bridge with the ClerkExpo module.
56-
@MainActor static func register() {
57-
shared.loadThemes()
58-
clerkNativeBridge = shared
59-
}
60-
6168
@MainActor
6269
func configure(publishableKey: String, bearerToken: String? = nil) async throws {
70+
loadThemes()
71+
6372
if Self.shouldReconfigure(for: publishableKey) {
6473
try await Clerk.reconfigure(publishableKey: publishableKey, options: Self.makeClerkOptions())
6574
Self.clerkConfigured = true
@@ -69,7 +78,7 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
6978
let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken)
7079
await Self.waitForLoadedClientIfNeeded(shouldWaitForClient)
7180
Self.emitClientChangedIfReceivedToken(bearerToken)
72-
emitClerkNativeBridgeReady()
81+
Self.postConfiguredNotification()
7382
return
7483
}
7584

@@ -89,13 +98,13 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
8998
let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken)
9099
await Self.waitForLoadedClientIfNeeded(shouldWaitForClient)
91100
Self.emitClientChangedIfReceivedToken(bearerToken)
92-
emitClerkNativeBridgeReady()
101+
Self.postConfiguredNotification()
93102
}
94103

95104
@MainActor
96105
private static func emitClientChangedIfReceivedToken(_ bearerToken: String?) {
97106
guard let token = bearerToken, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
98-
emitClerkNativeClientChanged(Self.clientChangedPayload())
107+
Self.emitClientChanged(Self.clientChangedPayload())
99108
}
100109

101110
@MainActor
@@ -124,7 +133,7 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
124133
if newClientState != self.lastObservedClientState {
125134
self.lastObservedClientState = newClientState
126135
let payload = Self.clientChangedPayload()
127-
emitClerkNativeClientChanged(payload)
136+
Self.emitClientChanged(payload)
128137
}
129138

130139
self.observeClient(generation: generation)
@@ -263,7 +272,25 @@ final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
263272
}
264273

265274
lastObservedClientState = Self.clientStateSnapshot()
266-
emitClerkNativeClientChanged(Self.clientChangedPayload(sourceId: sourceId))
275+
Self.emitClientChanged(Self.clientChangedPayload(sourceId: sourceId))
276+
}
277+
278+
private static func postConfiguredNotification() {
279+
NotificationCenter.default.post(name: .clerkNativeSDKDidConfigure, object: nil)
280+
}
281+
282+
static func setClientChangedEmitter(_ emitter: (([String: Any]?) -> Void)?) {
283+
clerkNativeClientEventQueue.sync {
284+
clerkNativeClientChangedEmitter = emitter
285+
}
286+
}
287+
288+
/// Requests that ClerkProvider reload the JS client from native client state.
289+
static func emitClientChanged(_ body: [String: Any]? = nil) {
290+
let emitter = clerkNativeClientEventQueue.sync {
291+
clerkNativeClientChangedEmitter
292+
}
293+
emitter?(body)
267294
}
268295

269296
private static func authMode(from mode: String) -> AuthView.Mode {

packages/expo/ios/ClerkNativeViewHost.swift

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import UIKit
22

3-
public class ClerkNativeViewHost: UIView, ClerkNativeBridgeReadyObserver {
3+
public class ClerkNativeViewHost: UIView {
44
private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self)
55
private var hasInitialized: Bool = false
6+
private var configuredObserver: NSObjectProtocol?
67

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

16+
deinit {
17+
removeConfiguredObserver()
18+
}
19+
1520
override public func didMoveToWindow() {
1621
super.didMoveToWindow()
1722

1823
guard window != nil else {
1924
if hasInitialized {
2025
hostedViewDidDetachFromWindow()
2126
}
22-
removeClerkNativeBridgeReadyObserver(self)
27+
removeConfiguredObserver()
2328
hostingCoordinator.detach()
2429
hasInitialized = false
2530
return
2631
}
2732

2833
guard !hasInitialized else { return }
2934
hasInitialized = true
30-
addClerkNativeBridgeReadyObserver(self)
35+
addConfiguredObserver()
3136
hostedViewDidAttachToWindow()
3237
updateHostedView()
3338
}
@@ -51,8 +56,22 @@ public class ClerkNativeViewHost: UIView, ClerkNativeBridgeReadyObserver {
5156

5257
func hostedViewDidDetachFromWindow() {}
5358

54-
public func clerkNativeBridgeDidBecomeReady() {
55-
setNeedsHostedViewUpdate()
59+
private func addConfiguredObserver() {
60+
guard configuredObserver == nil else { return }
61+
62+
configuredObserver = NotificationCenter.default.addObserver(
63+
forName: .clerkNativeSDKDidConfigure,
64+
object: nil,
65+
queue: .main
66+
) { [weak self] _ in
67+
self?.setNeedsHostedViewUpdate()
68+
}
69+
}
70+
71+
private func removeConfiguredObserver() {
72+
guard let configuredObserver else { return }
73+
NotificationCenter.default.removeObserver(configuredObserver)
74+
self.configuredObserver = nil
5675
}
5776

5877
private func updateHostedView() {

packages/expo/ios/ClerkUserButtonNativeView.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@ import UIKit
33

44
public class ClerkUserButtonNativeView: ClerkNativeViewHost {
55
override func makeHostedController() -> UIViewController? {
6-
guard let bridge = clerkNativeBridge else { return nil }
7-
8-
return bridge.makeUserButtonViewController()
6+
return ClerkNativeBridge.shared.makeUserButtonViewController()
97
}
108
}
119

0 commit comments

Comments
 (0)