diff --git a/.changeset/quiet-buttons-align.md b/.changeset/quiet-buttons-align.md new file mode 100644 index 00000000000..f7900d12c5c --- /dev/null +++ b/.changeset/quiet-buttons-align.md @@ -0,0 +1,5 @@ +--- +"@clerk/expo": minor +--- + +Align the iOS native Clerk module and native views with Android by registering them through Expo Modules. diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index a03cb1a0f78..f7c6ae64769 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -6,8 +6,7 @@ * 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 - * React Native autolinking on iOS (RCTViewManager). + * Native modules and views are registered via Expo Modules autolinking. */ const { withXcodeProject, @@ -187,8 +186,7 @@ const withClerkGoogleSignIn = config => { * 2. Android gets packaging exclusions for dependency conflicts * 3. Google Sign-In URL scheme is configured (if env var is set) * - * Native modules are registered via Expo Modules autolinking on Android and - * React Native autolinking on iOS (RCTViewManager). + * Native modules and views are registered via Expo Modules autolinking. */ /** * Write ClerkKeychainService to Info.plist when keychainService is provided. diff --git a/packages/expo/expo-module.config.json b/packages/expo/expo-module.config.json index 5b25775946c..355aef1e930 100644 --- a/packages/expo/expo-module.config.json +++ b/packages/expo/expo-module.config.json @@ -1,7 +1,13 @@ { "platforms": ["apple", "android"], "apple": { - "modules": ["ClerkGoogleSignInModule"] + "modules": [ + "ClerkExpoModule", + "ClerkAuthViewModule", + "ClerkUserProfileViewModule", + "ClerkUserButtonViewModule", + "ClerkGoogleSignInModule" + ] }, "android": { "modules": [ diff --git a/packages/expo/ios/ClerkAuthNativeView.swift b/packages/expo/ios/ClerkAuthNativeView.swift index 01a4295100d..eca8515bc16 100644 --- a/packages/expo/ios/ClerkAuthNativeView.swift +++ b/packages/expo/ios/ClerkAuthNativeView.swift @@ -1,4 +1,4 @@ -import React +import ExpoModulesCore import UIKit public class ClerkAuthNativeView: ClerkNativeViewHost { @@ -6,28 +6,24 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { private var currentDismissible: Bool = true private var didSendDismiss = false - @objc var onAuthEvent: RCTBubblingEventBlock? + let onAuthEvent = EventDispatcher() - @objc var mode: NSString? { - didSet { - let newMode = (mode as String?) ?? "signInOrUp" - guard newMode != currentMode else { return } - currentMode = newMode - setNeedsHostedViewUpdate() - } + func setMode(_ mode: String?) { + let newMode = mode ?? "signInOrUp" + guard newMode != currentMode else { return } + currentMode = newMode + setNeedsHostedViewUpdate() } - @objc var isDismissible: NSNumber? { - didSet { - let newDismissible = isDismissible?.boolValue ?? true - guard newDismissible != currentDismissible else { return } - currentDismissible = newDismissible - setNeedsHostedViewUpdate() - } + func setDismissible(_ isDismissible: Bool?) { + let newDismissible = isDismissible ?? true + guard newDismissible != currentDismissible else { return } + currentDismissible = newDismissible + setNeedsHostedViewUpdate() } private func sendAuthEvent(type: ClerkNativeViewEvent) { - onAuthEvent?(["type": type.rawValue]) + onAuthEvent(["type": type.rawValue]) } private func sendDismissIfNeeded() { @@ -58,14 +54,20 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { } } -@objc(ClerkAuthViewManager) -class ClerkAuthViewManager: RCTViewManager { +public class ClerkAuthViewModule: Module { + public func definition() -> ModuleDefinition { + Name("ClerkAuthView") - override static func requiresMainQueueSetup() -> Bool { - return true - } + View(ClerkAuthNativeView.self) { + Events("onAuthEvent") - override func view() -> UIView! { - return ClerkAuthNativeView() + Prop("mode") { (view: ClerkAuthNativeView, mode: String?) in + view.setMode(mode) + } + + Prop("isDismissible") { (view: ClerkAuthNativeView, isDismissible: Bool?) in + view.setDismissible(isDismissible) + } + } } } diff --git a/packages/expo/ios/ClerkAuthViewManager.m b/packages/expo/ios/ClerkAuthViewManager.m deleted file mode 100644 index 87dbc97da29..00000000000 --- a/packages/expo/ios/ClerkAuthViewManager.m +++ /dev/null @@ -1,9 +0,0 @@ -#import - -@interface RCT_EXTERN_MODULE(ClerkAuthViewManager, RCTViewManager) - -RCT_EXPORT_VIEW_PROPERTY(mode, NSString) -RCT_EXPORT_VIEW_PROPERTY(isDismissible, NSNumber) -RCT_EXPORT_VIEW_PROPERTY(onAuthEvent, RCTBubblingEventBlock) - -@end diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo/ios/ClerkExpo.podspec index 885a7101b6e..e148aaf2bc6 100644 --- a/packages/expo/ios/ClerkExpo.podspec +++ b/packages/expo/ios/ClerkExpo.podspec @@ -33,6 +33,8 @@ Pod::Spec.new do |s| s.source = { git: 'https://github.com/clerk/javascript' } s.static_framework = true + s.dependency 'ExpoModulesCore' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'SWIFT_COMPILATION_MODE' => 'wholemodule' @@ -50,14 +52,11 @@ Pod::Spec.new do |s| end s.source_files = "ClerkNativeBridge.swift", - "ClerkExpoModule.swift", "ClerkExpoModule.m", + "ClerkExpoModule.swift", "ClerkNativeViewHost.swift", "ClerkAuthNativeView.swift", - "ClerkAuthViewManager.m", "ClerkUserProfileNativeView.swift", - "ClerkUserProfileViewManager.m", - "ClerkUserButtonNativeView.swift", - "ClerkUserButtonViewManager.m" + "ClerkUserButtonNativeView.swift" install_modules_dependencies(s) end diff --git a/packages/expo/ios/ClerkExpoModule.m b/packages/expo/ios/ClerkExpoModule.m deleted file mode 100644 index 2d12e4be649..00000000000 --- a/packages/expo/ios/ClerkExpoModule.m +++ /dev/null @@ -1,21 +0,0 @@ -#import -#import - -@interface RCT_EXTERN_MODULE(ClerkExpo, RCTEventEmitter) - -RCT_EXTERN_METHOD(configure:(NSString *)publishableKey - bearerToken:(NSString *)bearerToken - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(getClientToken:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(syncClientStateFromJs:(id)deviceToken - sourceId:(id)sourceId - didChangeClient:(BOOL)didChangeClient - didChangeDeviceToken:(BOOL)didChangeDeviceToken - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) - -@end diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index c4a4658f36c..8c2f964ec2d 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -2,113 +2,114 @@ // This module provides the configure function, client sync, and native view bridges. // SwiftUI Clerk views are created by ClerkNativeBridge through the Clerk iOS SPM dependency. -import UIKit -import React +import ExpoModulesCore +import Foundation // MARK: - Module -@objc(ClerkExpo) -class ClerkExpoModule: RCTEventEmitter { - - private static var _hasListeners = false - private static weak var sharedInstance: ClerkExpoModule? - - override init() { - super.init() - ClerkExpoModule.sharedInstance = self - ClerkNativeBridge.setClientChangedEmitter { body in - Self.emitClientChanged(body) - } - } - - @objc override static func requiresMainQueueSetup() -> Bool { - return false - } - +public class ClerkExpoModule: Module { private static let nativeClientChangedEvent = "clerkNativeClientChanged" - override func supportedEvents() -> [String]! { - return [Self.nativeClientChangedEvent] - } + private static weak var sharedInstance: ClerkExpoModule? - override func startObserving() { - ClerkExpoModule._hasListeners = true - } + public func definition() -> ModuleDefinition { + Name("ClerkExpo") - override func stopObserving() { - ClerkExpoModule._hasListeners = false - } + Events(Self.nativeClientChangedEvent) - /// Emits a native client change event to JS from anywhere in the native layer. - /// Used by native views to ask ClerkProvider to reload JS client state. - static func emitClientChanged(_ body: [String: Any]? = nil) { - let eventBody = body ?? [:] + OnCreate { + Self.sharedInstance = self + ClerkNativeBridge.setClientChangedEmitter { body in + Self.emitClientChanged(body) + } + } - guard let instance = sharedInstance else { - return + OnDestroy { + if Self.sharedInstance === self { + Self.sharedInstance = nil + ClerkNativeBridge.setClientChangedEmitter(nil) + } } - if let bridge = instance.bridge { - bridge.enqueueJSCall("RCTDeviceEventEmitter", method: "emit", args: [nativeClientChangedEvent, eventBody], completion: nil) - return + AsyncFunction("configure") { (publishableKey: String, bearerToken: String?, promise: Promise) in + self.configure(publishableKey, bearerToken: bearerToken, promise: promise) } - guard _hasListeners else { - return + AsyncFunction("getClientToken") { (promise: Promise) in + self.getClientToken(promise: promise) } - instance.sendEvent(withName: nativeClientChangedEvent, body: eventBody) + AsyncFunction("syncClientStateFromJs") { + (deviceToken: String?, + sourceId: String?, + didChangeClient: Bool, + didChangeDeviceToken: Bool, + promise: Promise) in + self.syncClientStateFromJs( + deviceToken, + sourceId: sourceId, + didChangeClient: didChangeClient, + didChangeDeviceToken: didChangeDeviceToken, + promise: promise + ) + } } // MARK: - configure - @objc func configure(_ publishableKey: String, - bearerToken: String?, - resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock) { + private func configure(_ publishableKey: String, bearerToken: String?, promise: Promise) { Task { do { try await ClerkNativeBridge.shared.configure(publishableKey: publishableKey, bearerToken: bearerToken) - resolve(nil) + promise.resolve() } catch { - reject("E_CONFIGURE_FAILED", error.localizedDescription, error) + promise.reject("E_CONFIGURE_FAILED", error.localizedDescription) } } } // MARK: - getClientToken - @objc func getClientToken(_ resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock) { + private func getClientToken(promise: Promise) { Task { let token = await ClerkNativeBridge.shared.getClientToken() - resolve(token) + promise.resolve(token) } } // MARK: - syncClientStateFromJs - @objc func syncClientStateFromJs(_ deviceToken: Any?, - sourceId: Any?, - didChangeClient: Bool, - didChangeDeviceToken: Bool, - resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock) { - let normalizedDeviceToken = deviceToken as? String - let normalizedSourceId = sourceId as? String + private func syncClientStateFromJs(_ deviceToken: String?, + sourceId: String?, + didChangeClient: Bool, + didChangeDeviceToken: Bool, + promise: Promise) { Task { do { try await ClerkNativeBridge.shared.syncClientStateFromJs( - deviceToken: normalizedDeviceToken, - sourceId: normalizedSourceId, + deviceToken: deviceToken, + sourceId: sourceId, didChangeClient: didChangeClient, didChangeDeviceToken: didChangeDeviceToken ) - resolve(nil) + promise.resolve() } catch { - reject("E_SYNC_FROM_JS_FAILED", error.localizedDescription, error) + promise.reject("E_SYNC_FROM_JS_FAILED", error.localizedDescription) } } } + /// Emits a native client change event to JS from anywhere in the native layer. + /// Used by native views to ask ClerkProvider to reload JS client state. + static func emitClientChanged(_ body: [String: Any]? = nil) { + let eventBody = body ?? [:] + + guard let instance = sharedInstance else { + return + } + + DispatchQueue.main.async { [weak instance] in + instance?.sendEvent(Self.nativeClientChangedEvent, eventBody) + } + } } diff --git a/packages/expo/ios/ClerkNativeViewHost.swift b/packages/expo/ios/ClerkNativeViewHost.swift index d1ebce3be0b..0d91f0e749f 100644 --- a/packages/expo/ios/ClerkNativeViewHost.swift +++ b/packages/expo/ios/ClerkNativeViewHost.swift @@ -1,14 +1,16 @@ +import ExpoModulesCore import UIKit -public class ClerkNativeViewHost: UIView { +public class ClerkNativeViewHost: ExpoView { 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) + public required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) } + @available(*, unavailable) public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/packages/expo/ios/ClerkUserButtonNativeView.swift b/packages/expo/ios/ClerkUserButtonNativeView.swift index a3511ce865c..c32c7c8668c 100644 --- a/packages/expo/ios/ClerkUserButtonNativeView.swift +++ b/packages/expo/ios/ClerkUserButtonNativeView.swift @@ -1,4 +1,4 @@ -import React +import ExpoModulesCore import UIKit public class ClerkUserButtonNativeView: ClerkNativeViewHost { @@ -7,14 +7,10 @@ public class ClerkUserButtonNativeView: ClerkNativeViewHost { } } -@objc(ClerkUserButtonViewManager) -class ClerkUserButtonViewManager: RCTViewManager { +public class ClerkUserButtonViewModule: Module { + public func definition() -> ModuleDefinition { + Name("ClerkUserButtonView") - override static func requiresMainQueueSetup() -> Bool { - return true - } - - override func view() -> UIView! { - return ClerkUserButtonNativeView() + View(ClerkUserButtonNativeView.self) {} } } diff --git a/packages/expo/ios/ClerkUserButtonViewManager.m b/packages/expo/ios/ClerkUserButtonViewManager.m deleted file mode 100644 index 5d353edc6a4..00000000000 --- a/packages/expo/ios/ClerkUserButtonViewManager.m +++ /dev/null @@ -1,5 +0,0 @@ -#import - -@interface RCT_EXTERN_MODULE(ClerkUserButtonViewManager, RCTViewManager) - -@end diff --git a/packages/expo/ios/ClerkUserProfileNativeView.swift b/packages/expo/ios/ClerkUserProfileNativeView.swift index 012e1bd960c..78d8e298159 100644 --- a/packages/expo/ios/ClerkUserProfileNativeView.swift +++ b/packages/expo/ios/ClerkUserProfileNativeView.swift @@ -1,23 +1,21 @@ -import React +import ExpoModulesCore import UIKit public class ClerkUserProfileNativeView: ClerkNativeViewHost { private var currentDismissible: Bool = true private var didSendDismiss = false - @objc var onProfileEvent: RCTBubblingEventBlock? + let onProfileEvent = EventDispatcher() - @objc var isDismissible: NSNumber? { - didSet { - let newDismissible = isDismissible?.boolValue ?? true - guard newDismissible != currentDismissible else { return } - currentDismissible = newDismissible - setNeedsHostedViewUpdate() - } + func setDismissible(_ isDismissible: Bool?) { + let newDismissible = isDismissible ?? true + guard newDismissible != currentDismissible else { return } + currentDismissible = newDismissible + setNeedsHostedViewUpdate() } private func sendProfileEvent(type: ClerkNativeViewEvent) { - onProfileEvent?(["type": type.rawValue]) + onProfileEvent(["type": type.rawValue]) } private func sendDismissIfNeeded() { @@ -47,14 +45,16 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost { } } -@objc(ClerkUserProfileViewManager) -class ClerkUserProfileViewManager: RCTViewManager { +public class ClerkUserProfileViewModule: Module { + public func definition() -> ModuleDefinition { + Name("ClerkUserProfileView") - override static func requiresMainQueueSetup() -> Bool { - return true - } + View(ClerkUserProfileNativeView.self) { + Events("onProfileEvent") - override func view() -> UIView! { - return ClerkUserProfileNativeView() + Prop("isDismissible") { (view: ClerkUserProfileNativeView, isDismissible: Bool?) in + view.setDismissible(isDismissible) + } + } } } diff --git a/packages/expo/ios/ClerkUserProfileViewManager.m b/packages/expo/ios/ClerkUserProfileViewManager.m deleted file mode 100644 index ee06c66a125..00000000000 --- a/packages/expo/ios/ClerkUserProfileViewManager.m +++ /dev/null @@ -1,8 +0,0 @@ -#import - -@interface RCT_EXTERN_MODULE(ClerkUserProfileViewManager, RCTViewManager) - -RCT_EXPORT_VIEW_PROPERTY(isDismissible, NSNumber) -RCT_EXPORT_VIEW_PROPERTY(onProfileEvent, RCTBubblingEventBlock) - -@end diff --git a/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts b/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts index ee2ec36adc1..c0294ff52d7 100644 --- a/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts +++ b/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts @@ -5,25 +5,13 @@ import { type NativeClientSnapshot, useNativeClientEvents } from '../useNativeCl const mocks = vi.hoisted(() => { return { - addListener: vi.fn(), + moduleAddListener: vi.fn(), nativeModule: {} as unknown, nativeListener: undefined as ((snapshot?: NativeClientSnapshot) => void) | undefined, - platform: { - OS: 'ios', - }, remove: vi.fn(), }; }); -vi.mock('react-native', () => { - return { - DeviceEventEmitter: { - addListener: mocks.addListener, - }, - Platform: mocks.platform, - }; -}); - vi.mock('../../utils/native-module', () => { return { get ClerkExpoModule() { @@ -35,12 +23,13 @@ vi.mock('../../utils/native-module', () => { describe('useNativeClientEvents', () => { beforeEach(() => { - mocks.nativeModule = {}; + mocks.nativeModule = { + addListener: mocks.moduleAddListener, + }; mocks.nativeListener = undefined; - mocks.platform.OS = 'ios'; mocks.remove.mockReset(); - mocks.addListener.mockReset(); - mocks.addListener.mockImplementation((_eventName, listener) => { + mocks.moduleAddListener.mockReset(); + mocks.moduleAddListener.mockImplementation((_eventName, listener) => { mocks.nativeListener = listener; return { remove: mocks.remove }; }); @@ -53,7 +42,7 @@ describe('useNativeClientEvents', () => { test('stores native client change payloads', async () => { const { result, unmount } = renderHook(() => useNativeClientEvents()); - expect(mocks.addListener).toHaveBeenCalledWith('clerkNativeClientChanged', expect.any(Function)); + expect(mocks.moduleAddListener).toHaveBeenCalledWith('clerkNativeClientChanged', expect.any(Function)); act(() => { mocks.nativeListener?.({ @@ -78,8 +67,7 @@ describe('useNativeClientEvents', () => { unmount(); }); - test('does not subscribe Android modules without React Native addListener', () => { - mocks.platform.OS = 'android'; + test('does not subscribe modules without an Expo event emitter', () => { mocks.nativeModule = { configure: vi.fn(), getClientToken: vi.fn(), @@ -90,7 +78,7 @@ describe('useNativeClientEvents', () => { const { unmount } = renderHook(() => useNativeClientEvents()); - expect(mocks.addListener).not.toHaveBeenCalled(); + expect(mocks.moduleAddListener).not.toHaveBeenCalled(); expect(consoleError).not.toHaveBeenCalled(); consoleError.mockRestore(); diff --git a/packages/expo/src/hooks/useNativeClientEvents.ts b/packages/expo/src/hooks/useNativeClientEvents.ts index 5d44611980b..5d46e62958e 100644 --- a/packages/expo/src/hooks/useNativeClientEvents.ts +++ b/packages/expo/src/hooks/useNativeClientEvents.ts @@ -1,5 +1,4 @@ import { useEffect, useState } from 'react'; -import { DeviceEventEmitter, Platform } from 'react-native'; import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; @@ -37,10 +36,6 @@ type RefreshClientEventEmitter = { }; function getNativeClientEventEmitter(): RefreshClientEventEmitter | null { - if (Platform.OS === 'ios') { - return DeviceEventEmitter; - } - if (ClerkExpo && typeof ClerkExpo.addListener === 'function') { return ClerkExpo as RefreshClientEventEmitter; } diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx index e10997f9dff..416bcfbdef7 100644 --- a/packages/expo/src/native/AuthView.tsx +++ b/packages/expo/src/native/AuthView.tsx @@ -1,11 +1,12 @@ -import { type ComponentProps, type ReactElement, useCallback } from 'react'; +import { type ReactElement, useCallback } from 'react'; +import type { NativeSyntheticEvent } from 'react-native'; import { Text, View } from 'react-native'; import NativeClerkAuthView from '../specs/NativeClerkAuthView'; import { isNativeSupported } from '../utils/native-module'; import type { AuthViewProps } from './AuthView.types'; -type AuthNativeEvent = Parameters['onAuthEvent']>>[0]; +type AuthNativeEvent = NativeSyntheticEvent>; /** * A pre-built native authentication component that handles sign-in and sign-up flows. diff --git a/packages/expo/src/plugin/withClerkExpo.ts b/packages/expo/src/plugin/withClerkExpo.ts index 4f26423bc37..669ae271c2c 100644 --- a/packages/expo/src/plugin/withClerkExpo.ts +++ b/packages/expo/src/plugin/withClerkExpo.ts @@ -82,8 +82,7 @@ const withClerkGoogleSignIn: ConfigPlugin = config => { * 1. Configures iOS URL scheme for Google Sign-In (if env var is set) * 2. Adds Android packaging exclusions to resolve dependency conflicts * - * Native modules are registered via Expo Modules autolinking on Android and - * React Native autolinking on iOS (RCTViewManager). + * Native modules and views are registered via Expo Modules autolinking. */ const withClerkExpo: ConfigPlugin = config => { config = withClerkGoogleSignIn(config); diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index cb7c9af64fa..abebbc208a9 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -89,7 +89,6 @@ vi.mock('../../specs/NativeClerkModule', () => { addListener: vi.fn(), configure: mocks.configure, getClientToken: mocks.getClientToken, - removeListeners: vi.fn(), syncClientStateFromJs: mocks.syncClientStateFromJs, }, }; diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 6ea6eee4d77..1a24d566f07 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -787,10 +787,7 @@ export function useNativeClientBootstrap({ } } } catch (error) { - const isNativeModuleNotFound = - error instanceof Error && - (error.message.includes('Cannot find native module') || - error.message.includes("TurboModuleRegistry.getEnforcing(...): 'ClerkExpo'")); + const isNativeModuleNotFound = error instanceof Error && error.message.includes('Cannot find native module'); if (isNativeModuleNotFound) { if (__DEV__) { console.debug( diff --git a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts index 650cc4b51c0..a5f3b8b91c0 100644 --- a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts +++ b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts @@ -15,10 +15,6 @@ vi.mock('react-native', () => { Platform: { OS: 'ios', }, - NativeModules: {}, - TurboModuleRegistry: { - get: vi.fn(), - }, }; }); diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts index 925cac4b4a8..53a5ce01151 100644 --- a/packages/expo/src/provider/singleton/createClerkInstance.ts +++ b/packages/expo/src/provider/singleton/createClerkInstance.ts @@ -20,7 +20,8 @@ import { import { MemoryTokenCache } from '../../cache/MemoryTokenCache'; import { CLERK_CLIENT_JWT_KEY } from '../../constants'; import { errorThrower } from '../../errorThrower'; -import { assertValidProxyUrl, isNative } from '../../utils'; +import { assertValidProxyUrl } from '../../utils/errors'; +import { isNative } from '../../utils/runtime'; import type { BuildClerkOptions } from './types'; /** diff --git a/packages/expo/src/specs/NativeClerkAuthView.android.ts b/packages/expo/src/specs/NativeClerkAuthView.android.ts index c41bf58a5ea..3ff2855161a 100644 --- a/packages/expo/src/specs/NativeClerkAuthView.android.ts +++ b/packages/expo/src/specs/NativeClerkAuthView.android.ts @@ -1,13 +1,12 @@ import { requireNativeView } from 'expo'; -import type { ViewProps } from 'react-native'; +import type { NativeSyntheticEvent, ViewProps } from 'react-native'; type AuthEvent = Readonly<{ type: string }>; -type NativeEvent = Readonly<{ nativeEvent: T }>; interface NativeProps extends ViewProps { mode?: string; isDismissible?: boolean; - onAuthEvent?: (event: NativeEvent) => void; + onAuthEvent?: (event: NativeSyntheticEvent) => void; } export default requireNativeView('ClerkAuthView'); diff --git a/packages/expo/src/specs/NativeClerkAuthView.ts b/packages/expo/src/specs/NativeClerkAuthView.ts index 4e4d4ed3d2b..0d15ea73cc6 100644 --- a/packages/expo/src/specs/NativeClerkAuthView.ts +++ b/packages/expo/src/specs/NativeClerkAuthView.ts @@ -1,13 +1,16 @@ +import { requireNativeView } from 'expo'; import type { NativeSyntheticEvent, ViewProps } from 'react-native'; -import { requireNativeComponent } from 'react-native'; +import { Platform } from 'react-native'; type AuthEvent = Readonly<{ type: string }>; -type AuthEventHandler = (event: NativeSyntheticEvent) => void | Promise; interface NativeProps extends ViewProps { mode?: string; isDismissible?: boolean; - onAuthEvent?: AuthEventHandler; + onAuthEvent?: (event: NativeSyntheticEvent) => void; } -export default requireNativeComponent('ClerkAuthView'); +const NativeClerkAuthView = + Platform.OS === 'ios' || Platform.OS === 'android' ? requireNativeView('ClerkAuthView') : null; + +export default NativeClerkAuthView; diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index 39f8a744922..2cf67749368 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -1,9 +1,9 @@ import { requireNativeModule } from 'expo'; interface Spec { - // addListener/removeListeners are present on the iOS RN event emitter module. - // Android uses Expo Modules EventEmitter instead. - addListener?(eventName: string): void; + // Exposed by Expo Modules EventEmitter for internal native client change events. + // This is not part of the public @clerk/expo API. + addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; configure(publishableKey: string, bearerToken: string | null): Promise; getClientToken(): Promise; syncClientStateFromJs( @@ -12,7 +12,6 @@ interface Spec { didChangeClient: boolean, didChangeDeviceToken: boolean, ): Promise; - removeListeners?(count: number): void; } export default requireNativeModule('ClerkExpo'); diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index c77fa90e76f..c8eb967e84d 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -1,10 +1,9 @@ -import type { TurboModule } from 'react-native'; -import { TurboModuleRegistry } from 'react-native'; +import { requireOptionalNativeModule } from 'expo'; -export interface Spec extends TurboModule { - // Required by NativeEventEmitter for internal native client change events. +export interface Spec { + // Exposed by Expo Modules EventEmitter for internal native client change events. // This is not part of the public @clerk/expo API. - addListener(eventName: string): void; + addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; configure(publishableKey: string, bearerToken: string | null): Promise; getClientToken(): Promise; syncClientStateFromJs( @@ -13,9 +12,6 @@ export interface Spec extends TurboModule { didChangeClient: boolean, didChangeDeviceToken: boolean, ): Promise; - // Required by NativeEventEmitter for internal native client change events. - // This is not part of the public @clerk/expo API. - removeListeners(count: number): void; } -export default TurboModuleRegistry.get('ClerkExpo'); +export default requireOptionalNativeModule('ClerkExpo'); diff --git a/packages/expo/src/specs/NativeClerkModule.web.ts b/packages/expo/src/specs/NativeClerkModule.web.ts index bb4b30c6aa5..1ad23bffbc2 100644 --- a/packages/expo/src/specs/NativeClerkModule.web.ts +++ b/packages/expo/src/specs/NativeClerkModule.web.ts @@ -1,4 +1,4 @@ -// Web stub: TurboModuleRegistry doesn't exist on web, so we export null. +// Web stub: ClerkExpo native modules do not exist on web, so we export null. // Cast to any to match the native module's Spec | null type without circular imports. // Metro resolves this file on web via platform-specific extensions (.web.ts). export default null as any; diff --git a/packages/expo/src/specs/NativeClerkUserButtonView.ts b/packages/expo/src/specs/NativeClerkUserButtonView.ts index e62cad421a4..21c90423be1 100644 --- a/packages/expo/src/specs/NativeClerkUserButtonView.ts +++ b/packages/expo/src/specs/NativeClerkUserButtonView.ts @@ -1,7 +1,11 @@ +import { requireNativeView } from 'expo'; import type { ViewProps } from 'react-native'; -import { requireNativeComponent } from 'react-native'; +import { Platform } from 'react-native'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface NativeProps extends ViewProps {} -export default requireNativeComponent('ClerkUserButtonView'); +const NativeClerkUserButtonView = + Platform.OS === 'ios' || Platform.OS === 'android' ? requireNativeView('ClerkUserButtonView') : null; + +export default NativeClerkUserButtonView; diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.android.ts b/packages/expo/src/specs/NativeClerkUserProfileView.android.ts index 7adcbd11dcf..7ac253fb341 100644 --- a/packages/expo/src/specs/NativeClerkUserProfileView.android.ts +++ b/packages/expo/src/specs/NativeClerkUserProfileView.android.ts @@ -1,12 +1,11 @@ import { requireNativeView } from 'expo'; -import type { ViewProps } from 'react-native'; +import type { NativeSyntheticEvent, ViewProps } from 'react-native'; type ProfileEvent = Readonly<{ type: string }>; -type NativeEvent = Readonly<{ nativeEvent: T }>; interface NativeProps extends ViewProps { isDismissible?: boolean; - onProfileEvent?: (event: NativeEvent) => void; + onProfileEvent?: (event: NativeSyntheticEvent) => void; } export default requireNativeView('ClerkUserProfileView'); diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.ts b/packages/expo/src/specs/NativeClerkUserProfileView.ts index fc6b1b26834..819efcef803 100644 --- a/packages/expo/src/specs/NativeClerkUserProfileView.ts +++ b/packages/expo/src/specs/NativeClerkUserProfileView.ts @@ -1,12 +1,15 @@ +import { requireNativeView } from 'expo'; import type { NativeSyntheticEvent, ViewProps } from 'react-native'; -import { requireNativeComponent } from 'react-native'; +import { Platform } from 'react-native'; type ProfileEvent = Readonly<{ type: string }>; -type ProfileEventHandler = (event: NativeSyntheticEvent) => void | Promise; interface NativeProps extends ViewProps { isDismissible?: boolean; - onProfileEvent?: ProfileEventHandler; + onProfileEvent?: (event: NativeSyntheticEvent) => void; } -export default requireNativeComponent('ClerkUserProfileView'); +const NativeClerkUserProfileView = + Platform.OS === 'ios' || Platform.OS === 'android' ? requireNativeView('ClerkUserProfileView') : null; + +export default NativeClerkUserProfileView; diff --git a/packages/expo/src/specs/__tests__/native-view.web.test.ts b/packages/expo/src/specs/__tests__/native-view.web.test.ts new file mode 100644 index 00000000000..c15fc3fccdd --- /dev/null +++ b/packages/expo/src/specs/__tests__/native-view.web.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + requireNativeView: vi.fn(() => { + throw new Error('requireNativeView should not be called on web'); + }), +})); + +vi.mock('expo', () => ({ + requireNativeView: mocks.requireNativeView, +})); + +vi.mock('react-native', () => ({ + Platform: { + OS: 'web', + }, +})); + +async function importNativeViews() { + const [authView, userProfileView, userButtonView] = await Promise.all([ + import('../NativeClerkAuthView'), + import('../NativeClerkUserProfileView'), + import('../NativeClerkUserButtonView'), + ]); + + return [authView.default, userProfileView.default, userButtonView.default] as const; +} + +describe('native view specs on web', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + test('do not require native views at import time', async () => { + const [authView, userProfileView, userButtonView] = await importNativeViews(); + + expect(authView).toBeNull(); + expect(userProfileView).toBeNull(); + expect(userButtonView).toBeNull(); + expect(mocks.requireNativeView).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/expo/src/utils/__tests__/native-module.test.ts b/packages/expo/src/utils/__tests__/native-module.test.ts index ae1161bd80a..70d95547ba5 100644 --- a/packages/expo/src/utils/__tests__/native-module.test.ts +++ b/packages/expo/src/utils/__tests__/native-module.test.ts @@ -1,16 +1,13 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ - expoModule: undefined as unknown, nativeModule: undefined as unknown, - requireNativeModule: vi.fn(() => undefined as unknown), })); const makeNativeModule = ({ includeEventMethods = true } = {}) => ({ ...(includeEventMethods ? { addListener: vi.fn(), - removeListeners: vi.fn(), } : {}), configure: vi.fn(), @@ -24,10 +21,6 @@ vi.mock('react-native', () => ({ }, })); -vi.mock('expo', () => ({ - requireNativeModule: mocks.requireNativeModule, -})); - async function importNativeModule() { vi.doMock('../../specs/NativeClerkModule', () => ({ default: mocks.nativeModule, @@ -40,9 +33,7 @@ describe('native module loader', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - mocks.expoModule = undefined; mocks.nativeModule = undefined; - mocks.requireNativeModule.mockImplementation(() => mocks.expoModule); }); test('returns the generated native module when it satisfies the bootstrap contract', async () => { @@ -65,12 +56,6 @@ describe('native module loader', () => { mocks.nativeModule = { configure: vi.fn(), }; - mocks.expoModule = { - addListener: vi.fn(), - configure: vi.fn(), - getClientToken: vi.fn(), - removeListeners: vi.fn(), - }; const { ClerkExpoModule } = await importNativeModule(); diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts index df1bd0c3795..1a852882e4e 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -8,7 +8,6 @@ type ClerkExpoNativeModule = { addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; configure(publishableKey: string, bearerToken: string | null): Promise; getClientToken(): Promise; - removeListeners?(count: number): void; syncClientStateFromJs( deviceToken: string | null, sourceId: string | null, @@ -48,19 +47,11 @@ function loadNativeModule(): ClerkExpoNativeModule | null { return nativeModule; } - try { - // Expo SDK 54 can expose installed modules through Expo's module registry even - // when the generated TurboModule object is incomplete. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { requireNativeModule } = require('expo'); - const expoModule = requireNativeModule('ClerkExpo'); - return isClerkExpoModule(expoModule) ? expoModule : null; - } catch (e) { - if (__DEV__ && !nativeModule) { - console.warn('[ClerkExpo] Native module not available:', e); - } - return null; + if (__DEV__ && nativeModule) { + console.warn('[ClerkExpo] Native module does not satisfy the expected contract.'); } + + return null; } export const ClerkExpoModule = loadNativeModule();