diff --git a/README.md b/README.md index 1e5d900b..3f28c8d7 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,16 @@ On iOS, a `UIScene` host must install the wrapper's deep-link bridge before Reac NativeCustomerIO.configureSceneDeepLinkRouting() ``` -Then register the app's JavaScript `Linking` URL listener before calling `CustomerIO.initialize`. This ordering is required. Customer.io push, in-app, and inbox destinations are buffered during cold launch, then delivered through React Native's standard `Linking` API after initialization. If React Native does not initialize within ten seconds, the SDK opens the destination through the system instead of retaining it indefinitely. The listener owns the routing decision: navigate destinations your app handles, and use the app's normal external-browser path for other HTTP(S) destinations. React Native's native URL event has no handled result that the SDK can use for this decision, so a destination published after initialization without a listener is not opened externally, which would risk duplicate navigation. Natively initialized hosts, including Expo config-plugin integrations, keep their existing callback. Older React Native versions and AppDelegate-only hosts also keep their existing deep-link integration. +Then register the app's JavaScript `Linking` URL listener before calling `CustomerIO.initialize`. This ordering is required. The Expo plugin detects its scene lifecycle during initialization, so Expo app code does not call the native installation method itself. Customer.io push, in-app, and inbox destinations are buffered during cold launch, then delivered through React Native's standard `Linking` API after initialization. If React Native does not initialize within ten seconds, the SDK opens the destination through the system instead of retaining it indefinitely. The listener owns the routing decision: navigate destinations your app handles, and use the app's normal external-browser path for other HTTP(S) destinations. React Native's native URL event has no handled result that the SDK can use for this decision, so a destination published after initialization without a listener is not opened externally, which would risk duplicate navigation. Older React Native versions and AppDelegate-only hosts keep their existing deep-link integration. + +An Expo app using config-plugin auto-initialization does not call `CustomerIO.initialize`. Register its `Linking` listener, then mark that listener ready instead: + +```typescript +const subscription = Linking.addEventListener('url', ({ url }) => { + // Route the URL in your app. +}); +CustomerIO.setDeepLinkRoutingReady(); +``` This integration applies after the host has adopted React Native's UIScene lifecycle; the plugin does not replace React Native's root application lifecycle. diff --git a/__tests__/deep-link-routing-readiness.test.ts b/__tests__/deep-link-routing-readiness.test.ts new file mode 100644 index 00000000..98b24d92 --- /dev/null +++ b/__tests__/deep-link-routing-readiness.test.ts @@ -0,0 +1,50 @@ +jest.mock('react-native', () => ({ + Platform: { + OS: 'ios', + select: (spec: { [key: string]: unknown }) => + spec.ios ?? spec.default ?? undefined, + }, +})); + +jest.mock('../src/customerio-geofence', () => ({ + CustomerIOGeofence: class {}, +})); +jest.mock('../src/customerio-inapp', () => ({ + CustomerIOInAppMessaging: class {}, +})); +jest.mock('../src/customerio-liveactivities', () => ({ + CustomerIOLiveActivities: class {}, +})); +jest.mock('../src/customerio-location', () => ({ + CustomerIOLocation: class {}, +})); +jest.mock('../src/customerio-push', () => ({ + CustomerIOPushMessaging: class {}, +})); +jest.mock('../src/native-logger-listener', () => ({ + NativeLoggerListener: { + initNativeLogger: jest.fn(), + initialize: jest.fn(), + }, +})); +jest.mock('../src/specs/modules/NativeCustomerIO', () => ({ + __esModule: true, + default: { + setDeepLinkRoutingReady: jest.fn(), + }, +})); + +import { CustomerIO } from '../src/customerio-cdp'; +import NativeCustomerIO from '../src/specs/modules/NativeCustomerIO'; + +describe('CustomerIO scene deep-link readiness', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('notifies the native router after the host registers its Linking listener', () => { + CustomerIO.setDeepLinkRoutingReady(); + + expect(NativeCustomerIO.setDeepLinkRoutingReady).toHaveBeenCalledTimes(1); + }); +}); diff --git a/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt b/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt index dcd66a82..2fc0dc51 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt @@ -143,6 +143,10 @@ class NativeCustomerIOModule( } } + override fun setDeepLinkRoutingReady() { + // UIScene and React Native Linking readiness are iOS-only concerns. + } + override fun identify(params: ReadableMap?) { val userId = params?.getString("userId") val traits = params?.getMap("traits") diff --git a/api-extractor-output/customerio-reactnative.api.md b/api-extractor-output/customerio-reactnative.api.md index de162768..47125147 100644 --- a/api-extractor-output/customerio-reactnative.api.md +++ b/api-extractor-output/customerio-reactnative.api.md @@ -115,6 +115,7 @@ export class CustomerIO { static readonly pushMessaging: CustomerIOPushMessaging; static readonly registerDeviceToken: (token: string) => Promise; static readonly screen: (title: string, properties?: Record) => Promise; + static readonly setDeepLinkRoutingReady: () => void; static readonly setDeviceAttributes: (attributes: Record) => Promise; static readonly setProfileAttributes: (attributes: Record) => Promise; static readonly track: (name: string, properties?: Record) => Promise; diff --git a/customerio-reactnative.podspec b/customerio-reactnative.podspec index 8d443dbe..74cd05e2 100644 --- a/customerio-reactnative.podspec +++ b/customerio-reactnative.podspec @@ -14,6 +14,9 @@ Pod::Spec.new do |s| s.platforms = { :ios => min_ios_version_supported } s.source = { :git => "https://github.com/customerio/customerio-ios.git", :tag => "#{s.version}" } + # The generated Swift compatibility header imports + # in static-library builds. + s.header_dir = "customerio_reactnative" s.source_files = "ios/wrappers/**/*.{h,m,mm,swift}" # Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0. diff --git a/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift b/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift index 1b3546bf..39c11eb3 100644 --- a/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift +++ b/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift @@ -21,23 +21,51 @@ enum CustomerIOReactNativeDeepLinkRouter { private static var isReactNativeReady = false private static var pendingUrls: [PendingUrl] = [] + private static var hasSceneManifest: Bool { + guard let manifest = Bundle.main.object(forInfoDictionaryKey: sceneManifestKey) as? [String: Any], + let configurations = manifest[sceneConfigurationsKey] as? [String: Any] + else { return false } + + return !configurations.isEmpty + } + /// Mirrors `RCTIsSceneDelegateApp()` without linking to the internal symbol, and also requires /// React Native's scene-Linking entry point. A scene manifest alone does not make older React /// Native versions scene-aware. static var isSceneLifecycleEnabled: Bool { - guard let manifest = Bundle.main.object(forInfoDictionaryKey: sceneManifestKey) as? [String: Any], - let configurations = manifest[sceneConfigurationsKey] as? [String: Any] - else { return false } - guard !configurations.isEmpty, + guard hasSceneManifest, let linkingManager = NSClassFromString("RCTLinkingManager") else { return false } return linkingManager.responds(to: sceneOpenURLContextsSelector) } + /// Expo owns scene-to-Linking forwarding even when its React Native version does not expose + /// the scene selector itself. + static var isExpoSceneLifecycleEnabled: Bool { + hasSceneManifest && NSClassFromString("EXExpoAppSceneDelegate") != nil + } + static func install() { guard isSceneLifecycleEnabled else { return } + installCallback() + } + /// Expo owns scene-to-Linking forwarding even on React Native versions that do not expose the + /// scene selector themselves. Prefer the generic React Native capability when available, then + /// fall back to Expo's scene delegate capability. + static func installForExpoSceneLifecycle() { + guard isSceneLifecycleEnabled || isExpoSceneLifecycleEnabled else { + DIGraphShared.shared.logger.error( + "Customer.io could not install Expo scene deep-link routing because the host " + + "does not expose a supported React Native or Expo scene lifecycle" + ) + return + } + installCallback() + } + + private static func installCallback() { DIGraphShared.shared.deepLinkUtil.setDeepLinkCallback { url in accept(url) return true @@ -47,9 +75,17 @@ enum CustomerIOReactNativeDeepLinkRouter { static func accept(_ url: URL) { stateLock.lock() if !isReactNativeReady { + let isFirstPendingUrl = pendingUrls.isEmpty let pendingUrl = PendingUrl(id: UUID(), url: url) pendingUrls.append(pendingUrl) stateLock.unlock() + if isFirstPendingUrl { + DIGraphShared.shared.logger.info( + "Customer.io buffered an SDK deep link until React Native Linking is ready. " + + "Native-auto-initialized apps must call CustomerIO.setDeepLinkRoutingReady() " + + "after registering their Linking listener" + ) + } DispatchQueue.main.asyncAfter(deadline: .now() + readinessTimeout) { expire(pendingUrl.id) } @@ -111,7 +147,9 @@ enum CustomerIOReactNativeDeepLinkRouter { stateLock.unlock() DIGraphShared.shared.logger.error( - "Customer.io is opening an SDK deep link externally because React Native did not initialize in time" + "Customer.io is opening an SDK deep link externally because React Native did not " + + "initialize in time. Native-auto-initialized apps must call " + + "CustomerIO.setDeepLinkRoutingReady() after registering their Linking listener" ) UIApplication.shared.open(url) { opened in guard !opened else { return } diff --git a/ios/wrappers/NativeCustomerIO.mm b/ios/wrappers/NativeCustomerIO.mm index 8ff7170a..14cb690b 100644 --- a/ios/wrappers/NativeCustomerIO.mm +++ b/ios/wrappers/NativeCustomerIO.mm @@ -47,6 +47,11 @@ - (void)initialize:(NSDictionary *)config [_swiftBridge initialize:config args:args resolve:resolve reject:reject]; } +- (void)setDeepLinkRoutingReady { + [self assertBridgeAvailable:@"during setDeepLinkRoutingReady"]; + [_swiftBridge setDeepLinkRoutingReady]; +} + - (void)identify:(NSDictionary *)params { [self assertBridgeAvailable:@"during identify"]; [_swiftBridge identify:params]; diff --git a/ios/wrappers/NativeCustomerIO.swift b/ios/wrappers/NativeCustomerIO.swift index fc1ca96e..0234b3f7 100644 --- a/ios/wrappers/NativeCustomerIO.swift +++ b/ios/wrappers/NativeCustomerIO.swift @@ -2,6 +2,7 @@ import CioAnalytics import CioDataPipelines import CioInternalCommon import CioMessagingInApp +import Foundation @objc(NativeCustomerIO) public class NativeCustomerIO: NSObject { @@ -20,6 +21,23 @@ public class NativeCustomerIO: NSObject { CustomerIOReactNativeDeepLinkRouter.install() } + /// Installs deep-link routing for an Expo-owned scene lifecycle. + /// + /// Expo forwards scene URLs into React Native Linking itself, including on React Native + /// versions that do not expose the scene Linking selector used by the generic integration. + @objc + public static func configureExpoSceneDeepLinkRouting() { + guard CustomerIO.shared.implementation == nil else { return } + // Native initialization preserves an existing callback when its config does not provide one. + CustomerIOReactNativeDeepLinkRouter.installForExpoSceneLifecycle() + } + + /// Marks the React Native Linking listener as ready for buffered scene URLs. + @objc + func setDeepLinkRoutingReady() { + CustomerIOReactNativeDeepLinkRouter.markReactNativeReady() + } + /// Ensures that the CustomerIO SDK is initialized before performing operations. /// Logs an error and returns false if the SDK is not initialized. private func ensureInitialized() -> Bool { @@ -59,7 +77,10 @@ public class NativeCustomerIO: NSObject { let sdkConfigBuilder = try SDKConfigBuilder.create(from: config) - if CustomerIOReactNativeDeepLinkRouter.isSceneLifecycleEnabled { + let shouldInstallSceneRouter = + CustomerIOReactNativeDeepLinkRouter.isSceneLifecycleEnabled || + (packageSource == "Expo" && CustomerIOReactNativeDeepLinkRouter.isExpoSceneLifecycleEnabled) + if shouldInstallSceneRouter { _ = sdkConfigBuilder.deepLinkCallback { url in CustomerIOReactNativeDeepLinkRouter.accept(url) return true // React Native Linking cannot report handling; JavaScript owns fallback. diff --git a/ios/wrappers/customerio_reactnative.h b/ios/wrappers/customerio_reactnative.h new file mode 100644 index 00000000..b8e48597 --- /dev/null +++ b/ios/wrappers/customerio_reactnative.h @@ -0,0 +1,6 @@ +#pragma once + +#import + +// Public module header required when CocoaPods builds the wrapper as a static library. Swift's +// generated compatibility header imports this path before exposing the wrapper's Swift types. diff --git a/src/customerio-cdp.ts b/src/customerio-cdp.ts index 4e58a623..ee1e002d 100644 --- a/src/customerio-cdp.ts +++ b/src/customerio-cdp.ts @@ -56,6 +56,16 @@ export class CustomerIO { }); }; + /** + * Signal that the app's React Native Linking listener is ready to receive Customer.io URLs. + * + * This is only needed by UIScene hosts that initialize Customer.io natively, such as Expo + * config-plugin auto-initialization. Call it after registering the Linking listener. + */ + static readonly setDeepLinkRoutingReady = () => { + return withNativeModule((native) => native.setDeepLinkRoutingReady()); + }; + /** Identify a user to start tracking their activity. Requires userId, traits, or both. */ static readonly identify = async ({ userId, diff --git a/src/specs/modules/NativeCustomerIO.ts b/src/specs/modules/NativeCustomerIO.ts index 25988e03..114f7570 100644 --- a/src/specs/modules/NativeCustomerIO.ts +++ b/src/specs/modules/NativeCustomerIO.ts @@ -41,6 +41,7 @@ export interface Spec extends TurboModule { config: NativeBridgeObject, args: NativeBridgeObject ): Promise; + setDeepLinkRoutingReady(): void; identify(params?: NativeBridgeObject): void; clearIdentify(): void; track(name: string, properties?: NativeBridgeObject): void;