diff --git a/.changeset/tidy-expo-ios-import.md b/.changeset/tidy-expo-ios-import.md new file mode 100644 index 00000000000..498d537f59c --- /dev/null +++ b/.changeset/tidy-expo-ios-import.md @@ -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. diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index d1a70127aab..a03cb1a0f78 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -3,7 +3,7 @@ * Automatically configures iOS and Android to work with Clerk native components * * When this plugin is used: - * 1. iOS is configured with Swift Package Manager dependency for clerk-ios + * 1. iOS is configured with the required deployment target and metadata * 2. Android is configured with packaging exclusions for dependencies * * Native modules are registered via Expo Modules autolinking on Android and @@ -20,16 +20,8 @@ const path = require('path'); const fs = require('fs'); const packageJson = require('./package.json'); -const CLERK_IOS_REPO = 'https://github.com/clerk/clerk-ios.git'; -const CLERK_IOS_VERSION = '1.2.4'; -const CLERK_EXPO_VERSION_PLACEHOLDER = '__CLERK_EXPO_VERSION__'; - const CLERK_MIN_IOS_VERSION = '17.0'; -function injectClerkExpoVersion(source, version = packageJson.version) { - return source.split(CLERK_EXPO_VERSION_PLACEHOLDER).join(version); -} - const withClerkIOS = config => { console.log('✅ Clerk iOS plugin loaded'); @@ -88,376 +80,11 @@ const withClerkIOS = config => { return config; }); - // Then add the Swift Package dependency - config = withXcodeProject(config, config => { - const xcodeProject = config.modResults; - - try { - // Get the main app target - const targets = xcodeProject.getFirstTarget(); - if (!targets) { - console.warn('⚠️ Could not find main target in Xcode project'); - return config; - } - - const targetUuid = targets.uuid; - const targetName = targets.name; - - // Add Swift Package reference to the project - const packageUuid = xcodeProject.generateUuid(); - const packageName = 'clerk-ios'; - - // Add package reference to XCRemoteSwiftPackageReference section - if (!xcodeProject.hash.project.objects.XCRemoteSwiftPackageReference) { - xcodeProject.hash.project.objects.XCRemoteSwiftPackageReference = {}; - } - - xcodeProject.hash.project.objects.XCRemoteSwiftPackageReference[packageUuid] = { - isa: 'XCRemoteSwiftPackageReference', - repositoryURL: CLERK_IOS_REPO, - requirement: { - kind: 'exactVersion', - version: CLERK_IOS_VERSION, - }, - }; - - // Add package product dependencies (ClerkKit + ClerkKitUI) - const productUuidKit = xcodeProject.generateUuid(); - const productUuidKitUI = xcodeProject.generateUuid(); - if (!xcodeProject.hash.project.objects.XCSwiftPackageProductDependency) { - xcodeProject.hash.project.objects.XCSwiftPackageProductDependency = {}; - } - - xcodeProject.hash.project.objects.XCSwiftPackageProductDependency[productUuidKit] = { - isa: 'XCSwiftPackageProductDependency', - package: packageUuid, - productName: 'ClerkKit', - }; - - xcodeProject.hash.project.objects.XCSwiftPackageProductDependency[productUuidKitUI] = { - isa: 'XCSwiftPackageProductDependency', - package: packageUuid, - productName: 'ClerkKitUI', - }; - - // Add package to project's package references - const projectSection = xcodeProject.hash.project.objects.PBXProject; - const projectUuid = Object.keys(projectSection)[0]; - const project = projectSection[projectUuid]; - - if (!project.packageReferences) { - project.packageReferences = []; - } - - // Check if package is already added - const alreadyAdded = project.packageReferences.some(ref => { - const refObj = xcodeProject.hash.project.objects.XCRemoteSwiftPackageReference[ref.value]; - return refObj && refObj.repositoryURL === CLERK_IOS_REPO; - }); - - if (!alreadyAdded) { - project.packageReferences.push({ - value: packageUuid, - comment: packageName, - }); - } - - // Add package products to main app target - const nativeTarget = xcodeProject.hash.project.objects.PBXNativeTarget[targetUuid]; - if (!nativeTarget.packageProductDependencies) { - nativeTarget.packageProductDependencies = []; - } - - const kitAlreadyAdded = nativeTarget.packageProductDependencies.some(dep => dep.value === productUuidKit); - if (!kitAlreadyAdded) { - nativeTarget.packageProductDependencies.push({ - value: productUuidKit, - comment: 'ClerkKit', - }); - } - - const kitUIAlreadyAdded = nativeTarget.packageProductDependencies.some(dep => dep.value === productUuidKitUI); - if (!kitUIAlreadyAdded) { - nativeTarget.packageProductDependencies.push({ - value: productUuidKitUI, - comment: 'ClerkKitUI', - }); - } - - // Also add packages to ClerkExpo pod target if it exists - const allTargets = xcodeProject.hash.project.objects.PBXNativeTarget; - for (const [uuid, target] of Object.entries(allTargets)) { - if (target && target.name === 'ClerkExpo') { - if (!target.packageProductDependencies) { - target.packageProductDependencies = []; - } - - const podKitAdded = target.packageProductDependencies.some(dep => dep.value === productUuidKit); - if (!podKitAdded) { - target.packageProductDependencies.push({ - value: productUuidKit, - comment: 'ClerkKit', - }); - } - - const podKitUIAdded = target.packageProductDependencies.some(dep => dep.value === productUuidKitUI); - if (!podKitUIAdded) { - target.packageProductDependencies.push({ - value: productUuidKitUI, - comment: 'ClerkKitUI', - }); - } - - console.log(`✅ Added ClerkKit and ClerkKitUI packages to ClerkExpo pod target`); - } - } - - console.log(`✅ Added clerk-ios Swift package dependency (${CLERK_IOS_VERSION})`); - } catch (error) { - console.error('❌ Error adding clerk-ios package:', error.message); - } - - return config; - }); - - // Inject ClerkNativeBridge.register() call into AppDelegate.swift - config = withDangerousMod(config, [ - 'ios', - async config => { - const platformProjectRoot = config.modRequest.platformProjectRoot; - const projectName = config.modRequest.projectName; - const appDelegatePath = path.join(platformProjectRoot, projectName, 'AppDelegate.swift'); - - if (fs.existsSync(appDelegatePath)) { - let contents = fs.readFileSync(appDelegatePath, 'utf8'); - - // Check if already added - if (!contents.includes('ClerkNativeBridge.register()')) { - // Find the didFinishLaunchingWithOptions method and add the registration call - // Look for the return statement in didFinishLaunching - const pattern = /(func application\s*\([^)]*didFinishLaunchingWithOptions[^)]*\)[^{]*\{)/; - const match = contents.match(pattern); - - if (match) { - // Insert after the opening brace of didFinishLaunching - const insertPoint = match.index + match[0].length; - const registrationCode = '\n // Register Clerk native bridge\n ClerkNativeBridge.register()\n'; - contents = contents.slice(0, insertPoint) + registrationCode + contents.slice(insertPoint); - fs.writeFileSync(appDelegatePath, contents); - console.log('✅ Added ClerkNativeBridge.register() to AppDelegate.swift'); - } else { - console.warn('⚠️ Could not find didFinishLaunchingWithOptions in AppDelegate.swift'); - } - } - } - - return config; - }, - ]); - - // Then inject ClerkNativeBridge.swift into the app target - // This is required because the file uses `import ClerkKit` which is only available - // via SPM in the app target (CocoaPods targets can't see SPM packages) - config = withXcodeProject(config, config => { - try { - const platformProjectRoot = config.modRequest.platformProjectRoot; - const projectName = config.modRequest.projectName; - const iosProjectPath = path.join(platformProjectRoot, projectName); - - // Find the ClerkNativeBridge.swift source file using Node's module resolution, - // which handles arbitrary nesting depths in pnpm/yarn/npm workspaces. - let sourceFile; - try { - const packageRoot = path.dirname(require.resolve('@clerk/expo/package.json')); - sourceFile = path.join(packageRoot, 'ios', 'ClerkNativeBridge.swift'); - } catch { - sourceFile = null; - } - - if (sourceFile && fs.existsSync(sourceFile)) { - // ALWAYS copy the file to ensure we have the latest version - const targetFile = path.join(iosProjectPath, 'ClerkNativeBridge.swift'); - const sourceContents = fs.readFileSync(sourceFile, 'utf8'); - fs.writeFileSync(targetFile, injectClerkExpoVersion(sourceContents)); - console.log('✅ Copied ClerkNativeBridge.swift to app target'); - - // Add the file to the Xcode project manually - const xcodeProject = config.modResults; - const relativePath = `${projectName}/ClerkNativeBridge.swift`; - const fileName = 'ClerkNativeBridge.swift'; - - try { - // Get the main target - const target = xcodeProject.getFirstTarget(); - if (!target || !target.uuid) { - console.warn('⚠️ Could not find target UUID, file copied but not added to project'); - return config; - } - - const targetUuid = target.uuid; - - // Check if file is already in the Xcode project references - const fileReferences = xcodeProject.hash.project.objects.PBXFileReference || {}; - const alreadyExists = Object.values(fileReferences).some( - ref => ref && (ref.path === fileName || ref.path === relativePath || ref.name === fileName), - ); - - if (alreadyExists) { - // File is already in project, but we still copied the latest version - console.log('✅ ClerkNativeBridge.swift updated in app target'); - return config; - } - - // 1. Create PBXFileReference - const fileRefUuid = xcodeProject.generateUuid(); - if (!xcodeProject.hash.project.objects.PBXFileReference) { - xcodeProject.hash.project.objects.PBXFileReference = {}; - } - - xcodeProject.hash.project.objects.PBXFileReference[fileRefUuid] = { - isa: 'PBXFileReference', - lastKnownFileType: 'sourcecode.swift', - name: fileName, - path: relativePath, // Use full relative path (projectName/ClerkNativeBridge.swift) - sourceTree: '""', - }; - - // 2. Create PBXBuildFile - const buildFileUuid = xcodeProject.generateUuid(); - if (!xcodeProject.hash.project.objects.PBXBuildFile) { - xcodeProject.hash.project.objects.PBXBuildFile = {}; - } - - xcodeProject.hash.project.objects.PBXBuildFile[buildFileUuid] = { - isa: 'PBXBuildFile', - fileRef: fileRefUuid, - fileRef_comment: fileName, - }; - - // 3. Add to PBXSourcesBuildPhase - const buildPhases = xcodeProject.hash.project.objects.PBXSourcesBuildPhase || {}; - let sourcesPhaseUuid = null; - - // Find the sources build phase for the main target - const nativeTarget = xcodeProject.hash.project.objects.PBXNativeTarget[targetUuid]; - if (nativeTarget && nativeTarget.buildPhases) { - for (const phase of nativeTarget.buildPhases) { - if (buildPhases[phase.value] && buildPhases[phase.value].isa === 'PBXSourcesBuildPhase') { - sourcesPhaseUuid = phase.value; - break; - } - } - } - - if (sourcesPhaseUuid && buildPhases[sourcesPhaseUuid]) { - if (!buildPhases[sourcesPhaseUuid].files) { - buildPhases[sourcesPhaseUuid].files = []; - } - - buildPhases[sourcesPhaseUuid].files.push({ - value: buildFileUuid, - comment: fileName, - }); - } else { - console.warn('⚠️ Could not find PBXSourcesBuildPhase for target'); - } - - // 4. Add to PBXGroup (main group for the project) - const groups = xcodeProject.hash.project.objects.PBXGroup || {}; - let mainGroupUuid = null; - - // Find the group with the same name as the project - for (const [uuid, group] of Object.entries(groups)) { - if (group && group.name === projectName) { - mainGroupUuid = uuid; - break; - } - } - - if (mainGroupUuid && groups[mainGroupUuid]) { - if (!groups[mainGroupUuid].children) { - groups[mainGroupUuid].children = []; - } - - // Add file reference to the group - groups[mainGroupUuid].children.push({ - value: fileRefUuid, - comment: fileName, - }); - } else { - console.warn('⚠️ Could not find main PBXGroup for project'); - } - - console.log('✅ Added ClerkNativeBridge.swift to Xcode project'); - } catch (addError) { - console.error('❌ Error adding file to Xcode project:', addError.message); - console.error(addError.stack); - } - } else { - console.warn('⚠️ ClerkNativeBridge.swift not found, skipping injection'); - } - } catch (error) { - console.error('❌ Error injecting ClerkNativeBridge.swift:', error.message); - } - - return config; + config = withInfoPlist(config, modConfig => { + modConfig.modResults.ClerkExpoVersion = packageJson.version; + return modConfig; }); - // Inject SPM package resolution into Podfile post_install hook - // This runs synchronously during pod install, ensuring packages are resolved before prebuild completes - config = withDangerousMod(config, [ - 'ios', - async config => { - const platformProjectRoot = config.modRequest.platformProjectRoot; - const projectName = config.modRequest.projectName; - const podfilePath = path.join(platformProjectRoot, 'Podfile'); - - if (fs.existsSync(podfilePath)) { - let podfileContents = fs.readFileSync(podfilePath, 'utf8'); - - // Check if we've already added our resolution code - if (!podfileContents.includes('# Clerk: Resolve SPM packages')) { - // Code to inject into existing post_install block - // Note: We run this AFTER react_native_post_install to ensure the workspace is fully written - const spmResolutionCode = ` - # Clerk: Resolve SPM packages synchronously during pod install - # This ensures packages are downloaded before the user opens Xcode - # We wait until the end of post_install to ensure workspace is fully written - at_exit do - workspace_path = File.join(__dir__, '${projectName}.xcworkspace') - if File.exist?(workspace_path) - puts "" - puts "📦 [Clerk] Resolving Swift Package dependencies..." - puts " This may take a minute on first run..." - # Use backticks to capture output and check exit status - output = \`xcodebuild -resolvePackageDependencies -workspace "#{workspace_path}" -scheme "${projectName}" 2>&1\` - if $?.success? - puts "✅ [Clerk] Swift Package dependencies resolved successfully" - else - puts "⚠️ [Clerk] SPM resolution output:" - puts output.lines.last(10).join - end - puts "" - end - end -`; - - // Insert our code at the beginning of the existing post_install block - if (podfileContents.includes('post_install do |installer|')) { - podfileContents = podfileContents.replace( - /post_install do \|installer\|/, - `post_install do |installer|${spmResolutionCode}`, - ); - fs.writeFileSync(podfilePath, podfileContents); - console.log('✅ Added SPM resolution to Podfile post_install hook'); - } - } - } - - return config; - }, - ]); - return config; }; @@ -556,7 +183,7 @@ const withClerkGoogleSignIn = config => { * Combined Clerk Expo plugin * * When this plugin is configured in app.json/app.config.js: - * 1. iOS gets Swift Package Manager dependency for clerk-ios SDK + * 1. iOS gets the deployment target and metadata required by Clerk native views * 2. Android gets packaging exclusions for dependency conflicts * 3. Google Sign-In URL scheme is configured (if env var is set) * @@ -727,7 +354,6 @@ const withClerkExpo = (config, props = {}) => { module.exports = withClerkExpo; module.exports._testing = { validateThemeJson, - injectClerkExpoVersion, isPlainObject, VALID_COLOR_KEYS, HEX_COLOR_REGEX, diff --git a/packages/expo/ios/ClerkAuthNativeView.swift b/packages/expo/ios/ClerkAuthNativeView.swift index 601d3e5252f..01a4295100d 100644 --- a/packages/expo/ios/ClerkAuthNativeView.swift +++ b/packages/expo/ios/ClerkAuthNativeView.swift @@ -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 diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo/ios/ClerkExpo.podspec index d1d122982d2..885a7101b6e 100644 --- a/packages/expo/ios/ClerkExpo.podspec +++ b/packages/expo/ios/ClerkExpo.podspec @@ -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'] @@ -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.' + end + + s.source_files = "ClerkNativeBridge.swift", + "ClerkExpoModule.swift", "ClerkExpoModule.m", "ClerkNativeViewHost.swift", "ClerkAuthNativeView.swift", "ClerkAuthViewManager.m", diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index dc54aacbbe5..60016a7219e 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -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.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) @@ -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 { @@ -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) @@ -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) } } @@ -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 @@ -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) -} diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index 2a638d64ab2..45707afa7c3 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -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 @@ -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 @@ -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 } @@ -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 @@ -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) @@ -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 { diff --git a/packages/expo/ios/ClerkNativeViewHost.swift b/packages/expo/ios/ClerkNativeViewHost.swift index 445aa429f78..d1ebce3be0b 100644 --- a/packages/expo/ios/ClerkNativeViewHost.swift +++ b/packages/expo/ios/ClerkNativeViewHost.swift @@ -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) @@ -12,6 +13,10 @@ public class ClerkNativeViewHost: UIView, ClerkNativeBridgeReadyObserver { fatalError("init(coder:) has not been implemented") } + deinit { + removeConfiguredObserver() + } + override public func didMoveToWindow() { super.didMoveToWindow() @@ -19,7 +24,7 @@ public class ClerkNativeViewHost: UIView, ClerkNativeBridgeReadyObserver { if hasInitialized { hostedViewDidDetachFromWindow() } - removeClerkNativeBridgeReadyObserver(self) + removeConfiguredObserver() hostingCoordinator.detach() hasInitialized = false return @@ -27,7 +32,7 @@ public class ClerkNativeViewHost: UIView, ClerkNativeBridgeReadyObserver { guard !hasInitialized else { return } hasInitialized = true - addClerkNativeBridgeReadyObserver(self) + addConfiguredObserver() hostedViewDidAttachToWindow() updateHostedView() } @@ -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() { diff --git a/packages/expo/ios/ClerkUserButtonNativeView.swift b/packages/expo/ios/ClerkUserButtonNativeView.swift index 9fe39622752..a3511ce865c 100644 --- a/packages/expo/ios/ClerkUserButtonNativeView.swift +++ b/packages/expo/ios/ClerkUserButtonNativeView.swift @@ -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() } } diff --git a/packages/expo/ios/ClerkUserProfileNativeView.swift b/packages/expo/ios/ClerkUserProfileNativeView.swift index 412a3940ad9..012e1bd960c 100644 --- a/packages/expo/ios/ClerkUserProfileNativeView.swift +++ b/packages/expo/ios/ClerkUserProfileNativeView.swift @@ -36,9 +36,7 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost { } override func makeHostedController() -> UIViewController? { - guard let bridge = clerkNativeBridge else { return nil } - - return bridge.makeUserProfileViewController( + return ClerkNativeBridge.shared.makeUserProfileViewController( dismissible: currentDismissible, onEvent: { [weak self] event, _ in if event == .dismissed { diff --git a/packages/expo/package.json b/packages/expo/package.json index 2e1fbb59b61..31418e9895a 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -146,7 +146,7 @@ "expo-web-browser": ">=12.5.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", - "react-native": ">=0.73" + "react-native": ">=0.75" }, "peerDependenciesMeta": { "@clerk/expo-passkeys": { diff --git a/packages/expo/src/__tests__/appPlugin.injectClerkExpoVersion.test.js b/packages/expo/src/__tests__/appPlugin.injectClerkExpoVersion.test.js deleted file mode 100644 index 777811ed2fc..00000000000 --- a/packages/expo/src/__tests__/appPlugin.injectClerkExpoVersion.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export -const { injectClerkExpoVersion } = require('../../app.plugin.js')._testing; - -describe('injectClerkExpoVersion', () => { - test('replaces every Swift bridge version placeholder with the package version', () => { - expect( - injectClerkExpoVersion( - [ - 'request.addValue("__CLERK_EXPO_VERSION__", forHTTPHeaderField: "x-clerk-host-sdk-version")', - 'let version = "__CLERK_EXPO_VERSION__"', - ].join('\n'), - '3.4.3', - ), - ).toBe( - ['request.addValue("3.4.3", forHTTPHeaderField: "x-clerk-host-sdk-version")', 'let version = "3.4.3"'].join('\n'), - ); - }); -});