From dc17426f270da4e26e96e45e584a07887855deb8 Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Thu, 27 Aug 2026 23:36:03 -0400 Subject: [PATCH 1/6] feat: add acknowledged React Native deep-link routing (MBL-2301) --- .github/workflows/react-native-scene-e2e.yml | 11 ++ .maestro/fixtures/react-native-scene/App.tsx | 5 +- README.md | 29 ++- __tests__/deep-link-routing-readiness.test.ts | 131 +++++++++++- .../reactnative/sdk/NativeCustomerIOModule.kt | 12 ++ .../customerio-reactnative.api.md | 9 + ...merIOReactNativeDeepLinkRequestStore.swift | 100 ++++++++++ .../CustomerIOReactNativeDeepLinkRouter.swift | 187 +++++++++++++----- ios/wrappers/NativeCustomerIO.mm | 34 +++- ios/wrappers/NativeCustomerIO.swift | 47 +++++ .../liveactivities/NativeLiveActivities.swift | 9 +- .../test_ios_deep_link_request_store.swift | 114 +++++++++++ src/customerio-cdp.ts | 94 +++++++++ src/specs/modules/NativeCustomerIO.ts | 9 +- 14 files changed, 722 insertions(+), 69 deletions(-) create mode 100644 ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift create mode 100644 scripts/test_ios_deep_link_request_store.swift diff --git a/.github/workflows/react-native-scene-e2e.yml b/.github/workflows/react-native-scene-e2e.yml index ce488017..0245daaa 100644 --- a/.github/workflows/react-native-scene-e2e.yml +++ b/.github/workflows/react-native-scene-e2e.yml @@ -24,6 +24,7 @@ on: - 'src/utils/native-bridge.ts' - 'src/utils/param-validation.ts' - 'ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift' + - 'ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift' - 'ios/wrappers/NativeCustomerIO.swift' - 'ios/wrappers/NativeCustomerIO.mm' - 'ios/wrappers/liveactivities/NativeLiveActivities.swift' @@ -33,6 +34,7 @@ on: - 'ios/wrappers/CustomerioReactnative-Bridging-Header.h' - 'ios/cocoapods_deployment_target.rb' - 'customerio-reactnative.podspec' + - 'scripts/test_ios_deep_link_request_store.swift' - 'package.json' - 'package-lock.json' schedule: @@ -88,6 +90,15 @@ jobs: unzip -q "$maestro_zip" -d "$maestro_dir" echo "$maestro_dir/maestro/bin" >> "$GITHUB_PATH" + - name: Test deep-link acknowledgement state + shell: bash + run: | + xcrun swiftc \ + ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift \ + scripts/test_ios_deep_link_request_store.swift \ + -o "$RUNNER_TEMP/test-ios-deep-link-request-store" + "$RUNNER_TEMP/test-ios-deep-link-request-store" + - name: Test warm and terminated notification routing shell: bash env: diff --git a/.maestro/fixtures/react-native-scene/App.tsx b/.maestro/fixtures/react-native-scene/App.tsx index bcd2746f..c31b1022 100644 --- a/.maestro/fixtures/react-native-scene/App.tsx +++ b/.maestro/fixtures/react-native-scene/App.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Linking, Text, View } from 'react-native'; +import { Text, View } from 'react-native'; import { CioPushPermissionStatus, CioRegion, @@ -30,8 +30,9 @@ export default function App(): React.JSX.Element { const [failure, setFailure] = useState(null); useEffect(() => { - const subscription = Linking.addEventListener('url', ({ url }) => { + const subscription = CustomerIO.setDeepLinkHandler((url) => { setReceivedUrl(url); + return true; }); CustomerIO.initialize({ diff --git a/README.md b/README.md index c11686b0..82bbfb1d 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,24 @@ 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. 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. +Register the app's Customer.io deep-link handler before calling `CustomerIO.initialize`. Return `true` after routing a URL. Return `false` to let the native SDK try the host AppDelegate and then open the URL through the system. A thrown error, rejected promise, missing handler, or handler timeout follows the same fallback. Cold URLs wait up to ten seconds for registration and are replayed once the handler is ready. After delivery, the handler has ten seconds to settle. Return `false` only for URLs your app does not own; opening an app-owned URL through the system can deliver it back to your app lifecycle. -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 = CustomerIO.setDeepLinkHandler(async (url) => { + if (!canRouteInApp(url)) { + return false; + } + + await routeInApp(url); + return true; +}); + +CustomerIO.initialize(config); +``` + +Remove the returned subscription when the routing owner is torn down. The Expo plugin detects its scene lifecycle during native initialization, so Expo app code does not call `configureSceneDeepLinkRouting` or `CustomerIO.initialize`. In an Expo app, call `setDeepLinkHandler` when the root routing owner starts. + +The existing `Linking` readiness API remains available for backward compatibility: ```typescript const subscription = Linking.addEventListener('url', ({ url }) => { @@ -89,6 +104,8 @@ const subscription = Linking.addEventListener('url', ({ url }) => { CustomerIO.setDeepLinkRoutingReady(); ``` +This older path cannot acknowledge whether JavaScript handled the URL, so the SDK cannot safely fall back after it publishes a `Linking` event. Prefer `setDeepLinkHandler` for new UIScene integrations. Older React Native versions and AppDelegate-only hosts keep their existing deep-link integration. + This integration applies after the host has adopted React Native's UIScene lifecycle; the plugin does not replace React Native's root application lifecycle. This release's compatibility scope is one simultaneous window scene. Multiple simultaneous React Native window scenes are not supported. React Native's URL notification is process-wide, so SDK-published destinations may reach every connected React Native instance rather than one selected window. @@ -124,7 +141,7 @@ extension AppDelegate { A React Native `AppDelegate` conforms to `UIApplicationDelegate` directly rather than subclassing, so this method is not an `override`, and the URL is passed on to `RCTLinkingManager` instead of `super`. See [the sample app's `AppDelegate.swift`](/example/ios/SampleApp/AppDelegate.swift) for this in context. -For a `UIScene` host, handle both lifecycle paths. At scene connection, pass launch options through the wrapper so a cold Live Activity tap is attributed and React Native receives the customer's destination instead of Customer.io's internal tracking URL: +For a `UIScene` host, handle both lifecycle paths. At scene connection, pass the connection options through the wrapper so a cold Live Activity tap is attributed and its destination enters the same acknowledged scene router as other Customer.io deep links: ```swift import customerio_reactnative @@ -136,7 +153,7 @@ reactNativeFactory?.startReactNative( ) ``` -Then replace the ordinary React Native URL-forwarding body in the existing `SceneDelegate` for warm opens. This reports Live Activity taps and routes each remaining URL through React Native Linking: +Then replace the ordinary React Native URL-forwarding body in the existing `SceneDelegate` for warm opens. This reports Live Activity taps and routes each remaining URL through the same scene router: ```swift import customerio_reactnative @@ -148,8 +165,8 @@ func scene(_ scene: UIScene, openURLContexts URLContexts: Set) } ``` -Do not also pass those URLs to `RCTLinkingManager`. The helper already publishes them through -React Native Linking, and forwarding them again can deliver the same URL twice. +Do not also pass those URLs to `RCTLinkingManager`. The helper already routes them through the +Customer.io bridge, and forwarding them again can deliver the same URL twice. Android needs no equivalent native step. diff --git a/__tests__/deep-link-routing-readiness.test.ts b/__tests__/deep-link-routing-readiness.test.ts index 98b24d92..515fb2e7 100644 --- a/__tests__/deep-link-routing-readiness.test.ts +++ b/__tests__/deep-link-routing-readiness.test.ts @@ -23,28 +23,139 @@ jest.mock('../src/customerio-push', () => ({ })); jest.mock('../src/native-logger-listener', () => ({ NativeLoggerListener: { - initNativeLogger: jest.fn(), initialize: jest.fn(), + initNativeLogger: jest.fn(), + warn: jest.fn(), }, })); -jest.mock('../src/specs/modules/NativeCustomerIO', () => ({ - __esModule: true, - default: { +jest.mock('../src/specs/modules/NativeCustomerIO', () => { + const nativeModule = { setDeepLinkRoutingReady: jest.fn(), - }, -})); + registerDeepLinkHandler: jest.fn(), + unregisterDeepLinkHandler: jest.fn(), + acknowledgeDeepLink: jest.fn(), + onDeepLinkReceived: jest.fn((emitter) => { + nativeModule.__emit = emitter; + return { remove: nativeModule.__nativeRemove }; + }), + __emit: undefined, + __nativeRemove: jest.fn(), + }; + return { __esModule: true, default: nativeModule }; +}); -import { CustomerIO } from '../src/customerio-cdp'; +import { + CustomerIO, + type DeepLinkHandlerSubscription, +} from '../src/customerio-cdp'; +import { NativeLoggerListener } from '../src/native-logger-listener'; import NativeCustomerIO from '../src/specs/modules/NativeCustomerIO'; -describe('CustomerIO scene deep-link readiness', () => { +const native = NativeCustomerIO as unknown as { + setDeepLinkRoutingReady: jest.Mock; + registerDeepLinkHandler: jest.Mock; + unregisterDeepLinkHandler: jest.Mock; + acknowledgeDeepLink: jest.Mock; + onDeepLinkReceived: jest.Mock; + __nativeRemove: jest.Mock; + __emit?: (data: unknown) => Promise; +}; + +describe('CustomerIO scene deep-link routing', () => { + let subscription: DeepLinkHandlerSubscription | undefined; + beforeEach(() => { jest.clearAllMocks(); + native.__emit = undefined; + subscription = undefined; }); - it('notifies the native router after the host registers its Linking listener', () => { + afterEach(() => { + subscription?.remove(); + }); + + it('keeps the existing Linking readiness API', () => { CustomerIO.setDeepLinkRoutingReady(); - expect(NativeCustomerIO.setDeepLinkRoutingReady).toHaveBeenCalledTimes(1); + expect(native.setDeepLinkRoutingReady).toHaveBeenCalledTimes(1); + }); + + it('subscribes before telling native that the handler is ready', () => { + subscription = CustomerIO.setDeepLinkHandler(() => true); + + expect(native.onDeepLinkReceived).toHaveBeenCalledTimes(1); + expect(native.registerDeepLinkHandler).toHaveBeenCalledTimes(1); + expect(native.onDeepLinkReceived.mock.invocationCallOrder[0]).toBeLessThan( + native.registerDeepLinkHandler.mock.invocationCallOrder[0] + ); + }); + + it('acknowledges a handled URL', async () => { + const handler = jest.fn().mockResolvedValue(true); + subscription = CustomerIO.setDeepLinkHandler(handler); + + await native.__emit?.({ id: 'request-1', url: 'myapp://inbox' }); + + expect(handler).toHaveBeenCalledWith('myapp://inbox'); + expect(native.acknowledgeDeepLink).toHaveBeenCalledWith('request-1', true); + }); + + it('declines a URL when the handler returns false', async () => { + subscription = CustomerIO.setDeepLinkHandler(() => false); + + await native.__emit?.({ id: 'request-2', url: 'https://example.com' }); + + expect(native.acknowledgeDeepLink).toHaveBeenCalledWith('request-2', false); + }); + + it('declines a URL when the handler fails', async () => { + const error = new Error('route failed'); + subscription = CustomerIO.setDeepLinkHandler(() => Promise.reject(error)); + + await native.__emit?.({ id: 'request-3', url: 'myapp://settings' }); + + expect(NativeLoggerListener.warn).toHaveBeenCalledWith( + 'Deep-link handler failed:', + error + ); + expect(native.acknowledgeDeepLink).toHaveBeenCalledWith('request-3', false); + }); + + it('leaves malformed native events for the native timeout fallback', async () => { + subscription = CustomerIO.setDeepLinkHandler(() => true); + + await native.__emit?.({ id: 'request-4' }); + + expect(NativeLoggerListener.warn).toHaveBeenCalledWith( + 'Received an invalid native deep-link event.' + ); + expect(native.acknowledgeDeepLink).not.toHaveBeenCalled(); + }); + + it('unregisters native routing when the subscription is removed', () => { + subscription = CustomerIO.setDeepLinkHandler(() => true); + + subscription.remove(); + subscription.remove(); + + expect(native.unregisterDeepLinkHandler).toHaveBeenCalledTimes(1); + expect(native.__nativeRemove).toHaveBeenCalledTimes(1); + subscription = undefined; + }); + + it('unregisters the previous handler before replacing it', () => { + CustomerIO.setDeepLinkHandler(() => true); + + subscription = CustomerIO.setDeepLinkHandler(() => false); + + expect(native.unregisterDeepLinkHandler).toHaveBeenCalledTimes(1); + expect(native.__nativeRemove).toHaveBeenCalledTimes(1); + expect(native.registerDeepLinkHandler).toHaveBeenCalledTimes(2); + }); + + it('rejects a non-function handler', () => { + expect(() => CustomerIO.setDeepLinkHandler(null as never)).toThrow( + '[CustomerIO] "handler" must be a function.' + ); }); }); 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 2fc0dc51..dabfa9bc 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/NativeCustomerIOModule.kt @@ -147,6 +147,18 @@ class NativeCustomerIOModule( // UIScene and React Native Linking readiness are iOS-only concerns. } + override fun registerDeepLinkHandler() { + // UIScene deep-link acknowledgement is an iOS-only concern. + } + + override fun unregisterDeepLinkHandler() { + // UIScene deep-link acknowledgement is an iOS-only concern. + } + + override fun acknowledgeDeepLink(id: String?, handled: Boolean) { + // UIScene deep-link acknowledgement is an iOS-only concern. + } + 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 b74c5e19..5268bc39 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 setDeepLinkHandler: (handler: DeepLinkHandler) => DeepLinkHandlerSubscription; static readonly setDeepLinkRoutingReady: () => void; static readonly setDeviceAttributes: (attributes: Record) => Promise; static readonly setProfileAttributes: (attributes: Record) => Promise; @@ -179,6 +180,14 @@ export class CustomerIOPushMessaging implements NativePushSpec { trackNotificationResponseReceived(payload: Object): void; } +// @public +export type DeepLinkHandler = (url: string) => boolean | Promise; + +// @public +export interface DeepLinkHandlerSubscription { + remove(): void; +} + // @public export interface IdentifyParams { // (undocumented) diff --git a/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift b/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift new file mode 100644 index 00000000..7b637e2a --- /dev/null +++ b/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift @@ -0,0 +1,100 @@ +import Foundation + +struct CustomerIOReactNativeDeepLinkRequestStore { + enum Acceptance: Equatable { + case buffered(UUID) + case linking(URL) + case handler(UUID, URL) + } + + enum Resolution: Equatable { + case handled + case fallback(URL) + } + + private enum DeliveryMode: Equatable { + case unavailable + case linking + case handler + } + + private enum RequestState: Equatable { + case buffered + case awaitingAcknowledgement + } + + private struct Request { + let id: UUID + let url: URL + var state: RequestState + } + + private var deliveryMode: DeliveryMode = .unavailable + private var requests: [Request] = [] + + mutating func accept(_ url: URL) -> Acceptance { + switch deliveryMode { + case .unavailable: + let request = Request(id: UUID(), url: url, state: .buffered) + requests.append(request) + return .buffered(request.id) + case .linking: + return .linking(url) + case .handler: + let request = Request(id: UUID(), url: url, state: .awaitingAcknowledgement) + requests.append(request) + return .handler(request.id, request.url) + } + } + + mutating func useLinking() -> [URL] { + guard deliveryMode != .handler else { return [] } + + deliveryMode = .linking + let urls = requests.compactMap { request in + request.state == .buffered ? request.url : nil + } + requests.removeAll { $0.state == .buffered } + return urls + } + + mutating func useHandler() -> [(UUID, URL)] { + deliveryMode = .handler + var deliveries: [(UUID, URL)] = [] + for index in requests.indices where requests[index].state == .buffered { + requests[index].state = .awaitingAcknowledgement + deliveries.append((requests[index].id, requests[index].url)) + } + return deliveries + } + + mutating func removeHandler() { + guard deliveryMode == .handler else { return } + deliveryMode = .unavailable + } + + mutating func acknowledge(_ id: UUID, handled: Bool) -> Resolution? { + guard let index = requests.firstIndex(where: { + $0.id == id && $0.state == .awaitingAcknowledgement + }) else { return nil } + + let url = requests.remove(at: index).url + return handled ? .handled : .fallback(url) + } + + mutating func expireReadiness(_ id: UUID) -> URL? { + expire(id, expectedState: .buffered) + } + + mutating func expireAcknowledgement(_ id: UUID) -> URL? { + expire(id, expectedState: .awaitingAcknowledgement) + } + + private mutating func expire(_ id: UUID, expectedState: RequestState) -> URL? { + guard let index = requests.firstIndex(where: { + $0.id == id && $0.state == expectedState + }) else { return nil } + + return requests.remove(at: index).url + } +} diff --git a/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift b/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift index 39c11eb3..e8422411 100644 --- a/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift +++ b/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift @@ -3,12 +3,8 @@ import Foundation import UIKit enum CustomerIOReactNativeDeepLinkRouter { - private struct PendingUrl { - let id: UUID - let url: URL - } - private static let readinessTimeout: TimeInterval = 10 + private static let acknowledgementTimeout: TimeInterval = 10 private static let sceneManifestKey = "UIApplicationSceneManifest" private static let sceneConfigurationsKey = "UISceneConfigurations" private static let sceneOpenURLContextsSelector = NSSelectorFromString("scene:openURLContexts:") @@ -16,10 +12,16 @@ enum CustomerIOReactNativeDeepLinkRouter { // notification name and payload for Linking URL events. private static let openURLNotification = Notification.Name("RCTOpenURLNotification") private static let stateLock = NSLock() - // React Native Linking is process-wide. Multi-window and independent bridge lifecycles are - // outside the single-window scene contract, so readiness intentionally lasts for the process. - private static var isReactNativeReady = false - private static var pendingUrls: [PendingUrl] = [] + private static var requestStore = CustomerIOReactNativeDeepLinkRequestStore() + private static var handlerEmitter: ((_ id: String, _ url: String) -> Void)? + private static var handlerToken: UUID? + private static var hasInstalledCallback = false + + static var isInstalled: Bool { + stateLock.lock() + defer { stateLock.unlock() } + return hasInstalledCallback + } private static var hasSceneManifest: Bool { guard let manifest = Bundle.main.object(forInfoDictionaryKey: sceneManifestKey) as? [String: Any], @@ -66,6 +68,9 @@ enum CustomerIOReactNativeDeepLinkRouter { } private static func installCallback() { + stateLock.lock() + hasInstalledCallback = true + stateLock.unlock() DIGraphShared.shared.deepLinkUtil.setDeepLinkCallback { url in accept(url) return true @@ -74,38 +79,30 @@ 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" - ) - } + let acceptance = requestStore.accept(url) + stateLock.unlock() + + switch acceptance { + case let .buffered(id): + DIGraphShared.shared.logger.info( + "Customer.io buffered an SDK deep link until a React Native route is ready" + ) DispatchQueue.main.asyncAfter(deadline: .now() + readinessTimeout) { - expire(pendingUrl.id) + expireReadiness(id) } - return + case let .linking(url): + route(url) + case let .handler(id, url): + publishToHandler(id: id, url: url) } - stateLock.unlock() - - route(url) } static func markReactNativeReady() { let markReady = { stateLock.lock() - isReactNativeReady = true - let urls = pendingUrls.map(\.url) - pendingUrls.removeAll() + let urls = requestStore.useLinking() stateLock.unlock() - // This runs on the main thread, so buffered URLs are published before a new - // main-thread URL can interleave with them. urls.forEach(route) } if Thread.isMainThread { @@ -115,6 +112,61 @@ enum CustomerIOReactNativeDeepLinkRouter { } } + static func registerHandler( + _ emitter: @escaping (_ id: String, _ url: String) -> Void + ) -> UUID { + let token = UUID() + stateLock.lock() + handlerToken = token + handlerEmitter = emitter + let deliveries = requestStore.useHandler() + stateLock.unlock() + + for (id, url) in deliveries { + publishToHandler(id: id, url: url) + } + return token + } + + static func unregisterHandler(_ token: UUID) { + stateLock.lock() + guard handlerToken == token else { + stateLock.unlock() + return + } + handlerToken = nil + handlerEmitter = nil + requestStore.removeHandler() + stateLock.unlock() + } + + static func acknowledge(_ id: String, handled: Bool) { + guard let requestId = UUID(uuidString: id) else { + DIGraphShared.shared.logger.error( + "Customer.io ignored an invalid React Native deep-link acknowledgement" + ) + return + } + + stateLock.lock() + let resolution = requestStore.acknowledge(requestId, handled: handled) + stateLock.unlock() + + switch resolution { + case .handled: + DIGraphShared.shared.logger.info( + "React Native acknowledged the Customer.io deep link" + ) + case let .fallback(url): + DIGraphShared.shared.logger.info( + "React Native declined the Customer.io deep link; using the native fallback" + ) + fallback(url) + case nil: + break + } + } + static func route(_ url: URL) { let publish = { // React Native's AppDelegate URL entry point rejects scene-based hosts. Linking listens @@ -135,27 +187,70 @@ enum CustomerIOReactNativeDeepLinkRouter { } } - private static func expire(_ id: UUID) { - stateLock.lock() - guard !isReactNativeReady, - let index = pendingUrls.firstIndex(where: { $0.id == id }) - else { + private static func publishToHandler(id: UUID, url: URL) { + let publish = { + stateLock.lock() + let emitter = handlerEmitter stateLock.unlock() - return + + emitter?(id.uuidString, url.absoluteString) + DispatchQueue.main.asyncAfter(deadline: .now() + acknowledgementTimeout) { + expireAcknowledgement(id) + } + } + if Thread.isMainThread { + publish() + } else { + DispatchQueue.main.async(execute: publish) } - let url = pendingUrls.remove(at: index).url + } + + private static func expireReadiness(_ id: UUID) { + stateLock.lock() + let url = requestStore.expireReadiness(id) + stateLock.unlock() + + guard let url else { return } + + DIGraphShared.shared.logger.error( + "Customer.io is using the native fallback because React Native deep-link routing " + + "did not become ready in time" + ) + fallback(url) + } + + private static func expireAcknowledgement(_ id: UUID) { + stateLock.lock() + let url = requestStore.expireAcknowledgement(id) stateLock.unlock() + guard let url else { return } + DIGraphShared.shared.logger.error( - "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" + "Customer.io is using the native fallback because the React Native deep-link " + + "handler did not acknowledge the URL in time" ) - UIApplication.shared.open(url) { opened in - guard !opened else { return } - DIGraphShared.shared.logger.error( - "Customer.io could not open the SDK deep link externally" - ) + fallback(url) + } + + private static func fallback(_ url: URL) { + let open = { + let uiKit = DIGraphShared.shared.uIKitWrapper + if uiKit.continueNSUserActivity(webpageURL: url) { + DIGraphShared.shared.logger.info( + "Customer.io deep link was handled by the host AppDelegate fallback" + ) + } else { + uiKit.open(url: url) + DIGraphShared.shared.logger.info( + "Customer.io opened the deep link through the system fallback" + ) + } + } + if Thread.isMainThread { + open() + } else { + DispatchQueue.main.async(execute: open) } } } diff --git a/ios/wrappers/NativeCustomerIO.mm b/ios/wrappers/NativeCustomerIO.mm index 14cb690b..23ccd432 100644 --- a/ios/wrappers/NativeCustomerIO.mm +++ b/ios/wrappers/NativeCustomerIO.mm @@ -1,11 +1,16 @@ #import "utils/RCTCustomerIOUtils.h" #import +#import #import +@protocol NativeCustomerIOBridge +- (void)setEventEmitter:(id)emitter; +@end + // Objective-C wrapper for new architecture TurboModule implementation -@interface RCTNativeCustomerIO : NSObject +@interface RCTNativeCustomerIO : NativeCustomerIOSpecBase // Bridge to Swift implementation for cross-language compatibility -@property(nonatomic, strong) id swiftBridge; +@property(nonatomic, strong) id swiftBridge; @end @implementation RCTNativeCustomerIO @@ -30,6 +35,7 @@ - (instancetype)init { RCT_ASSERT_NOT_NIL(swiftClass, @"NativeCustomerIO Swift class", @"during runtime lookup"); _swiftBridge = [[swiftClass alloc] init]; [self assertBridgeAvailable:@"creating NativeCustomerIO Swift instance"]; + [_swiftBridge setEventEmitter:self]; } return self; } @@ -52,6 +58,30 @@ - (void)setDeepLinkRoutingReady { [_swiftBridge setDeepLinkRoutingReady]; } +- (void)registerDeepLinkHandler { + [self assertBridgeAvailable:@"during registerDeepLinkHandler"]; + [_swiftBridge registerDeepLinkHandler]; +} + +- (void)unregisterDeepLinkHandler { + [self assertBridgeAvailable:@"during unregisterDeepLinkHandler"]; + [_swiftBridge unregisterDeepLinkHandler]; +} + +- (void)acknowledgeDeepLink:(NSString *)id handled:(BOOL)handled { + [self assertBridgeAvailable:@"during acknowledgeDeepLink"]; + [_swiftBridge acknowledgeDeepLink:id handled:handled]; +} + +- (void)invalidate { + [self assertBridgeAvailable:@"during invalidate"]; + [_swiftBridge invalidate]; +} + +- (void)emitOnDeepLinkReceived:(NSDictionary *)value { + [super emitOnDeepLinkReceived:value]; +} + - (void)identify:(NSDictionary *)params { [self assertBridgeAvailable:@"during identify"]; [_swiftBridge identify:params]; diff --git a/ios/wrappers/NativeCustomerIO.swift b/ios/wrappers/NativeCustomerIO.swift index 0234b3f7..e2941f20 100644 --- a/ios/wrappers/NativeCustomerIO.swift +++ b/ios/wrappers/NativeCustomerIO.swift @@ -7,6 +7,8 @@ import Foundation @objc(NativeCustomerIO) public class NativeCustomerIO: NSObject { private let logger: CioInternalCommon.Logger = DIGraphShared.shared.logger + private weak var objcEventEmitter: AnyObject? + private var deepLinkHandlerToken: UUID? /// Checks whether the CustomerIO SDK has been initialized. /// Returns `true` if the SDK has been successfully initialized, `false` otherwise. private var isInitialized: Bool { CustomerIO.shared.implementation != nil } @@ -38,6 +40,51 @@ public class NativeCustomerIO: NSObject { CustomerIOReactNativeDeepLinkRouter.markReactNativeReady() } + @objc + public func setEventEmitter(_ emitter: AnyObject) { + objcEventEmitter = emitter + } + + @objc + func registerDeepLinkHandler() { + guard objcEventEmitter != nil else { + logger.error("Customer.io could not register the React Native deep-link handler") + return + } + + deepLinkHandlerToken = CustomerIOReactNativeDeepLinkRouter.registerHandler { [weak self] id, url in + self?.sendDeepLink(id: id, url: url) + } + } + + @objc + func unregisterDeepLinkHandler() { + guard let token = deepLinkHandlerToken else { return } + CustomerIOReactNativeDeepLinkRouter.unregisterHandler(token) + deepLinkHandlerToken = nil + } + + @objc + func acknowledgeDeepLink(_ id: String, handled: Bool) { + CustomerIOReactNativeDeepLinkRouter.acknowledge(id, handled: handled) + } + + @objc + func invalidate() { + unregisterDeepLinkHandler() + } + + private func sendDeepLink(id: String, url: String) { + guard let emitter = objcEventEmitter else { return } + + let selector = Selector(("emitOnDeepLinkReceived:")) + guard emitter.responds(to: selector) else { + logger.error("Customer.io could not emit a React Native deep-link event") + return + } + _ = emitter.perform(selector, with: ["id": id, "url": url] as NSDictionary) + } + /// 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 { diff --git a/ios/wrappers/liveactivities/NativeLiveActivities.swift b/ios/wrappers/liveactivities/NativeLiveActivities.swift index c6487f45..88f897d5 100644 --- a/ios/wrappers/liveactivities/NativeLiveActivities.swift +++ b/ios/wrappers/liveactivities/NativeLiveActivities.swift @@ -246,7 +246,8 @@ public class NativeLiveActivities: NSObject { } /// Build React Native launch options from a scene connection, reporting a cold Live Activity - /// tap and replacing Customer.io's internal tracking URL with its destination. This mirrors the + /// tap and routing its destination through the installed Customer.io scene router. Without an + /// installed router, the destination remains in React Native's launch options. This mirrors the /// `RCTConvertConnectionOptionsToLaunchOptions` conversion verified in React Native /// 0.88.0-nightly-20260823-0c7f63a4e. Before `CustomerIO.initialize`, the native Live Activities /// stub parses the redirect and buffers the opened metric for the module to flush at initialization. @@ -259,7 +260,11 @@ public class NativeLiveActivities: NSObject { if let url = connectionOptions.urlContexts.first?.url, let routableUrl = handleWidgetUrl(url) { - launchOptions[.url] = routableUrl + if CustomerIOReactNativeDeepLinkRouter.isInstalled { + CustomerIOReactNativeDeepLinkRouter.accept(routableUrl) + } else { + launchOptions[.url] = routableUrl + } } if let userActivity = connectionOptions.userActivities.first { diff --git a/scripts/test_ios_deep_link_request_store.swift b/scripts/test_ios_deep_link_request_store.swift new file mode 100644 index 00000000..b1f07e39 --- /dev/null +++ b/scripts/test_ios_deep_link_request_store.swift @@ -0,0 +1,114 @@ +import Foundation + +private func require(_ condition: @autoclosure () -> Bool, _ message: String) { + guard condition() else { + fputs("error: \(message)\n", stderr) + exit(1) + } +} + +private func bufferedRequest( + _ acceptance: CustomerIOReactNativeDeepLinkRequestStore.Acceptance +) -> UUID { + guard case let .buffered(id) = acceptance else { + fputs("error: expected a buffered request\n", stderr) + exit(1) + } + return id +} + +private func handlerRequest( + _ acceptance: CustomerIOReactNativeDeepLinkRequestStore.Acceptance +) -> UUID { + guard case let .handler(id, _) = acceptance else { + fputs("error: expected a handler request\n", stderr) + exit(1) + } + return id +} + +@main +private enum DeepLinkRequestStoreTests { + static func main() { + let coldUrl = URL(string: "myapp://cold")! + var lateStore = CustomerIOReactNativeDeepLinkRequestStore() + let coldId = bufferedRequest(lateStore.accept(coldUrl)) + let replay = lateStore.useHandler() + require(replay.count == 1, "late handler must receive one buffered URL") + require(replay[0].0 == coldId, "replay must preserve the request ID") + require(replay[0].1 == coldUrl, "replay must preserve the URL") + require( + lateStore.acknowledge(coldId, handled: true) == .handled, + "handled acknowledgement must resolve the request" + ) + require( + lateStore.expireAcknowledgement(coldId) == nil, + "resolved request must not fall back after its timeout" + ) + + let declinedUrl = URL(string: "https://example.com/declined")! + var declinedStore = CustomerIOReactNativeDeepLinkRequestStore() + _ = declinedStore.useHandler() + let declinedId = handlerRequest(declinedStore.accept(declinedUrl)) + require( + declinedStore.acknowledge(declinedId, handled: false) == .fallback(declinedUrl), + "declined request must select fallback" + ) + require( + declinedStore.acknowledge(declinedId, handled: true) == nil, + "request must resolve exactly once" + ) + + let absentUrl = URL(string: "myapp://absent")! + var absentStore = CustomerIOReactNativeDeepLinkRequestStore() + let absentId = bufferedRequest(absentStore.accept(absentUrl)) + require( + absentStore.expireReadiness(absentId) == absentUrl, + "missing handler must fall back after the readiness timeout" + ) + require( + absentStore.useHandler().isEmpty, + "an expired request must not replay to a late handler" + ) + + let timeoutUrl = URL(string: "myapp://timeout")! + var timeoutStore = CustomerIOReactNativeDeepLinkRequestStore() + _ = timeoutStore.useHandler() + let timeoutId = handlerRequest(timeoutStore.accept(timeoutUrl)) + require( + timeoutStore.expireAcknowledgement(timeoutId) == timeoutUrl, + "unacknowledged handler request must fall back" + ) + require( + timeoutStore.acknowledge(timeoutId, handled: true) == nil, + "late acknowledgement must not resolve an expired request" + ) + + let linkingUrl = URL(string: "myapp://legacy-linking")! + var linkingStore = CustomerIOReactNativeDeepLinkRequestStore() + let linkingId = bufferedRequest(linkingStore.accept(linkingUrl)) + require( + linkingStore.useLinking() == [linkingUrl], + "legacy Linking readiness must replay buffered URLs" + ) + require( + linkingStore.expireReadiness(linkingId) == nil, + "a URL replayed to Linking must not later fall back" + ) + + let handlerUrl = URL(string: "myapp://handler-wins")! + var handlerStore = CustomerIOReactNativeDeepLinkRequestStore() + _ = handlerStore.useHandler() + let handlerId = handlerRequest(handlerStore.accept(handlerUrl)) + require( + handlerStore.useLinking().isEmpty, + "initialization must not replace an explicit handler with Linking" + ) + require( + handlerStore.acknowledge(handlerId, handled: true) == .handled, + "explicit handler must remain able to acknowledge the URL" + ) + + print("React Native deep-link request store tests passed") + } +} diff --git a/src/customerio-cdp.ts b/src/customerio-cdp.ts index ee1e002d..ddab9f58 100644 --- a/src/customerio-cdp.ts +++ b/src/customerio-cdp.ts @@ -23,6 +23,22 @@ const packageJson = require('customerio-reactnative/package.json'); // Track whether CustomerIO SDK has been initialized to prevent usage before setup let _initialized = false; +let deepLinkHandlerSubscription: DeepLinkHandlerSubscription | undefined; + +/** + * Handles a Customer.io deep-link destination delivered from iOS. + * @public + */ +export type DeepLinkHandler = (url: string) => boolean | Promise; + +/** + * Removes a Customer.io deep-link handler. + * @public + */ +export interface DeepLinkHandlerSubscription { + /** Stop routing Customer.io deep links through this handler. */ + remove(): void; +} // Reference to the native CustomerIO Data Pipelines module for SDK operations const nativeModule = ensureNativeModule(NativeModule); @@ -66,6 +82,84 @@ export class CustomerIO { return withNativeModule((native) => native.setDeepLinkRoutingReady()); }; + /** + * Register an acknowledged deep-link handler for an iOS UIScene host. + * + * Register this before `initialize`. Return `true` after routing a URL. Returning `false`, + * throwing, or rejecting lets the native SDK try the host AppDelegate and then the system. + * Cold URLs are buffered until this handler is registered, subject to the native timeout. + */ + static readonly setDeepLinkHandler = ( + handler: DeepLinkHandler + ): DeepLinkHandlerSubscription => { + if (typeof handler !== 'function') { + throw new Error('[CustomerIO] "handler" must be a function.'); + } + + deepLinkHandlerSubscription?.remove(); + + return withNativeModule((native) => { + const nativeSubscription = native.onDeepLinkReceived(async (data) => { + const event = data as { id?: unknown; url?: unknown }; + if (typeof event.id !== 'string' || typeof event.url !== 'string') { + NativeLoggerListener.warn( + 'Received an invalid native deep-link event.' + ); + return; + } + + let handled = false; + try { + handled = (await handler(event.url)) === true; + } catch (error) { + NativeLoggerListener.warn('Deep-link handler failed:', error); + } + + try { + native.acknowledgeDeepLink(event.id, handled); + } catch (error) { + NativeLoggerListener.warn( + 'Failed to acknowledge a deep-link result:', + error + ); + } + }); + + let removed = false; + const subscription: DeepLinkHandlerSubscription = { + remove: () => { + if (removed) { + return; + } + removed = true; + try { + native.unregisterDeepLinkHandler(); + } finally { + nativeSubscription.remove(); + if (deepLinkHandlerSubscription === subscription) { + deepLinkHandlerSubscription = undefined; + } + } + }, + }; + + try { + native.registerDeepLinkHandler(); + } catch (error) { + try { + native.unregisterDeepLinkHandler(); + } catch { + // Preserve the registration error; the native timeout still owns pending URLs. + } + nativeSubscription.remove(); + throw error; + } + + deepLinkHandlerSubscription = subscription; + return subscription; + }); + }; + /** 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 114f7570..c14d1e80 100644 --- a/src/specs/modules/NativeCustomerIO.ts +++ b/src/specs/modules/NativeCustomerIO.ts @@ -1,5 +1,8 @@ import { TurboModuleRegistry, type TurboModule } from 'react-native'; -import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypes'; +import type { + EventEmitter, + UnsafeObject, +} from 'react-native/Libraries/Types/CodegenTypes'; /** * Native module specification for CustomerIO React Native SDK @@ -42,6 +45,10 @@ export interface Spec extends TurboModule { args: NativeBridgeObject ): Promise; setDeepLinkRoutingReady(): void; + readonly onDeepLinkReceived: EventEmitter; + registerDeepLinkHandler(): void; + unregisterDeepLinkHandler(): void; + acknowledgeDeepLink(id: string, handled: boolean): void; identify(params?: NativeBridgeObject): void; clearIdentify(): void; track(name: string, properties?: NativeBridgeObject): void; From dbd5951876815ad9299590c298748c40d821ec36 Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Fri, 28 Aug 2026 00:31:18 -0400 Subject: [PATCH 2/6] fix: preserve React Native scene routing ownership --- .github/workflows/react-native-scene-e2e.yml | 2 + .../fixtures/customerio_scene_declined.apns | 16 ++++ .maestro/fixtures/react-native-scene/App.tsx | 84 +++++++++++++------ .../react-native-scene/SceneDelegate.swift | 20 ++++- .maestro/run_scene_push.sh | 44 ++++++---- .maestro/scene_push_declined.yaml | 15 ++++ .maestro/scene_push_prepare.yaml | 5 +- README.md | 8 +- ...merIOReactNativeDeepLinkRequestStore.swift | 46 ++++++++-- .../CustomerIOReactNativeDeepLinkRouter.swift | 53 +++++++++--- ios/wrappers/NativeCustomerIO.swift | 11 +++ .../liveactivities/NativeLiveActivities.swift | 8 +- .../test_ios_deep_link_request_store.swift | 39 +++++++++ src/customerio-cdp.ts | 8 +- 14 files changed, 288 insertions(+), 71 deletions(-) create mode 100644 .maestro/fixtures/customerio_scene_declined.apns create mode 100644 .maestro/scene_push_declined.yaml diff --git a/.github/workflows/react-native-scene-e2e.yml b/.github/workflows/react-native-scene-e2e.yml index 0245daaa..24624d52 100644 --- a/.github/workflows/react-native-scene-e2e.yml +++ b/.github/workflows/react-native-scene-e2e.yml @@ -5,10 +5,12 @@ on: paths: - '.github/workflows/react-native-scene-e2e.yml' - '.maestro/fixtures/customerio_scene_cold.apns' + - '.maestro/fixtures/customerio_scene_declined.apns' - '.maestro/fixtures/customerio_scene_warm.apns' - '.maestro/fixtures/react-native-scene/**' - '.maestro/run_scene_push.sh' - '.maestro/scene_push_open.yaml' + - '.maestro/scene_push_declined.yaml' - '.maestro/scene_push_prepare.yaml' - '.maestro/scene_push_warm.yaml' - 'src/customerio-cdp.ts' diff --git a/.maestro/fixtures/customerio_scene_declined.apns b/.maestro/fixtures/customerio_scene_declined.apns new file mode 100644 index 00000000..375c2bfe --- /dev/null +++ b/.maestro/fixtures/customerio_scene_declined.apns @@ -0,0 +1,16 @@ +{ + "aps": { + "alert": { + "title": "Customer.io scene routing", + "body": "Decline a React Native app-owned destination" + }, + "sound": "default" + }, + "CIO-Delivery-ID": "maestro-scene-route-declined", + "CIO-Delivery-Token": "maestro-scene-token-declined", + "CIO": { + "push": { + "link": "cio-rn-scene-e2e://declined" + } + } +} diff --git a/.maestro/fixtures/react-native-scene/App.tsx b/.maestro/fixtures/react-native-scene/App.tsx index c31b1022..48102015 100644 --- a/.maestro/fixtures/react-native-scene/App.tsx +++ b/.maestro/fixtures/react-native-scene/App.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Text, View } from 'react-native'; +import { Linking, Settings, Text, View } from 'react-native'; import { CioPushPermissionStatus, CioRegion, @@ -25,38 +25,72 @@ async function validateLiveActivityBridge(): Promise { } export default function App(): React.JSX.Element { + const routingMode = + Settings.get('CioSceneE2EPersistedMode') === 'linking' + ? 'linking' + : 'acknowledged'; const [receivedUrl, setReceivedUrl] = useState(null); + const [declineCount, setDeclineCount] = useState(0); const [initialized, setInitialized] = useState(false); const [failure, setFailure] = useState(null); useEffect(() => { - const subscription = CustomerIO.setDeepLinkHandler((url) => { - setReceivedUrl(url); - return true; - }); + let active = true; + let subscription: { remove(): void } | undefined; - CustomerIO.initialize({ - cdpApiKey: 'scene-e2e-key', - region: CioRegion.US, - }) - .then(() => - CustomerIO.pushMessaging.showPromptForPushNotifications({ - ios: { sound: true, badge: true }, - }) - ) - .then(async (status) => { - if (status !== CioPushPermissionStatus.Granted) { - throw new Error(`Push permission is ${status}`); + const initialize = async () => { + if (routingMode === 'linking') { + subscription = Linking.addEventListener('url', ({ url }) => { + setReceivedUrl(url); + }); + CustomerIO.setDeepLinkRoutingReady(); + } + + await CustomerIO.initialize({ + cdpApiKey: 'scene-e2e-key', + region: CioRegion.US, + }); + + // Register after initialization in acknowledged mode. This intentionally exercises the cold + // replay boundary where initialization must not drain the URL into legacy Linking. + if (routingMode === 'acknowledged') { + const handlerSubscription = CustomerIO.setDeepLinkHandler((url) => { + if (url === 'cio-rn-scene-e2e://declined') { + setDeclineCount((count) => count + 1); + return false; + } + setReceivedUrl(url); + return true; + }); + if (!active) { + handlerSubscription.remove(); + return; } - await validateLiveActivityBridge(); - setInitialized(true); - }) - .catch((error: unknown) => { + subscription = handlerSubscription; + } + + const status = + await CustomerIO.pushMessaging.showPromptForPushNotifications({ + ios: { sound: true, badge: true }, + }); + if (status !== CioPushPermissionStatus.Granted) { + throw new Error(`Push permission is ${status}`); + } + await validateLiveActivityBridge(); + setInitialized(true); + }; + + initialize().catch((error: unknown) => { + if (active) { setFailure(error instanceof Error ? error.message : String(error)); - }); + } + }); - return () => subscription.remove(); - }, []); + return () => { + active = false; + subscription?.remove(); + }; + }, [routingMode]); return ( @@ -64,8 +98,10 @@ export default function App(): React.JSX.Element { Customer.io React Native scene E2E{' '} {initialized ? 'ready' : 'initializing'} + Routing: {routingMode} {failure && Initialization failed: {failure}} {receivedUrl && Received: {receivedUrl}} + {declineCount > 0 && Declined count: {declineCount}} ); } diff --git a/.maestro/fixtures/react-native-scene/SceneDelegate.swift b/.maestro/fixtures/react-native-scene/SceneDelegate.swift index d6a42bdc..ee90ac31 100644 --- a/.maestro/fixtures/react-native-scene/SceneDelegate.swift +++ b/.maestro/fixtures/react-native-scene/SceneDelegate.swift @@ -5,6 +5,9 @@ import UIKit import customerio_reactnative class SceneDelegate: RCTDefaultReactNativeFactoryDelegate, UIWindowSceneDelegate { + private static let launchModeKey = "CioSceneE2EMode" + private static let persistedModeKey = "CioSceneE2EPersistedMode" + var window: UIWindow? var reactNativeFactory: RCTReactNativeFactory? @@ -15,7 +18,11 @@ class SceneDelegate: RCTDefaultReactNativeFactoryDelegate, UIWindowSceneDelegate ) { guard let windowScene = scene as? UIWindowScene else { return } - NativeCustomerIO.configureSceneDeepLinkRouting() + if usesAcknowledgedHandler { + NativeCustomerIO.configureAcknowledgedSceneDeepLinkRouting() + } else { + NativeCustomerIO.configureSceneDeepLinkRouting() + } dependencyProvider = RCTAppDependencyProvider() reactNativeFactory = RCTReactNativeFactory(delegate: self) window = UIWindow(windowScene: windowScene) @@ -41,4 +48,15 @@ class SceneDelegate: RCTDefaultReactNativeFactoryDelegate, UIWindowSceneDelegate override func bundleURL() -> URL? { Bundle.main.url(forResource: "main", withExtension: "jsbundle") } + + private var usesAcknowledgedHandler: Bool { + let arguments = ProcessInfo.processInfo.arguments + if arguments.contains(Self.launchModeKey) || arguments.contains("-\(Self.launchModeKey)"), + let mode = UserDefaults.standard.string(forKey: Self.launchModeKey) + { + UserDefaults.standard.set(mode, forKey: Self.persistedModeKey) + } + + return UserDefaults.standard.string(forKey: Self.persistedModeKey) != "linking" + } } diff --git a/.maestro/run_scene_push.sh b/.maestro/run_scene_push.sh index 831e6678..9b913d3d 100755 --- a/.maestro/run_scene_push.sh +++ b/.maestro/run_scene_push.sh @@ -110,7 +110,7 @@ cleanup() { else echo "**Classification:** ${current_phase}-failed" fi - echo '**Scope:** simulator notification presentation, tap, and exact React Native Linking destination; no backend delivery or attribution claim' + echo '**Scope:** simulator notification presentation, tap, and exact acknowledged-handler and legacy-Linking destinations; no backend delivery or attribution claim' } >> "$GITHUB_STEP_SUMMARY" fi return "$exit_code" @@ -239,6 +239,10 @@ plist="ios/$APP_NAME/Info.plist" /usr/libexec/PlistBuddy -c 'Add :UIApplicationSceneManifest:UISceneConfigurations:UIWindowSceneSessionRoleApplication:0:UISceneConfigurationName string Default Configuration' "$plist" # shellcheck disable=SC2016 /usr/libexec/PlistBuddy -c 'Add :UIApplicationSceneManifest:UISceneConfigurations:UIWindowSceneSessionRoleApplication:0:UISceneDelegateClassName string $(PRODUCT_MODULE_NAME).SceneDelegate' "$plist" +/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes array' "$plist" +/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes:0 dict' "$plist" +/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes:0:CFBundleURLSchemes array' "$plist" +/usr/libexec/PlistBuddy -c 'Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string cio-rn-scene-e2e' "$plist" current_phase="pods" bundle install @@ -263,19 +267,25 @@ xcrun simctl install "$device_id" "$app_path" installed_app=true cd "$REPO_ROOT" -current_phase="prepare" -prepare_args=(--device "$device_id" test .maestro/scene_push_prepare.yaml) -if [[ -n "${RUNNER_TEMP:-}" ]]; then - prepare_args=( - --device "$device_id" - test - --debug-output "$RUNNER_TEMP/react-native-scene-maestro-scene_push_prepare" - --flatten-debug-output - .maestro/scene_push_prepare.yaml - ) -fi -maestro "${prepare_args[@]}" -xcrun simctl terminate "$device_id" "$APP_ID" -current_phase="routing" -run_notification_flow .maestro/scene_push_open.yaml .maestro/fixtures/customerio_scene_cold.apns -run_notification_flow .maestro/scene_push_warm.yaml .maestro/fixtures/customerio_scene_warm.apns +for routing_mode in acknowledged linking; do + current_phase="prepare-$routing_mode" + prepare_args=(--device "$device_id" test -e "ROUTING_MODE=$routing_mode" .maestro/scene_push_prepare.yaml) + if [[ -n "${RUNNER_TEMP:-}" ]]; then + prepare_args=( + --device "$device_id" + test + --debug-output "$RUNNER_TEMP/react-native-scene-maestro-scene_push_prepare-$routing_mode" + --flatten-debug-output + -e "ROUTING_MODE=$routing_mode" + .maestro/scene_push_prepare.yaml + ) + fi + maestro "${prepare_args[@]}" + xcrun simctl terminate "$device_id" "$APP_ID" + current_phase="routing-$routing_mode" + run_notification_flow .maestro/scene_push_open.yaml .maestro/fixtures/customerio_scene_cold.apns + run_notification_flow .maestro/scene_push_warm.yaml .maestro/fixtures/customerio_scene_warm.apns + if [[ "$routing_mode" == acknowledged ]]; then + run_notification_flow .maestro/scene_push_declined.yaml .maestro/fixtures/customerio_scene_declined.apns + fi +done diff --git a/.maestro/scene_push_declined.yaml b/.maestro/scene_push_declined.yaml new file mode 100644 index 00000000..34c80b94 --- /dev/null +++ b/.maestro/scene_push_declined.yaml @@ -0,0 +1,15 @@ +appId: org.reactjs.native.example.CioRnSceneHost +name: React Native UIScene declined app-owned URL +--- +- pressKey: Home +- extendedWaitUntil: + visible: + id: 'NotificationShortLookView' + timeout: 60000 +- tapOn: + id: 'NotificationShortLookView' + retryTapIfNoChange: false +- extendedWaitUntil: + visible: 'Declined count: 1' + timeout: 15000 +- assertNotVisible: 'Declined count: 2' diff --git a/.maestro/scene_push_prepare.yaml b/.maestro/scene_push_prepare.yaml index 4c8180f8..76c06bec 100644 --- a/.maestro/scene_push_prepare.yaml +++ b/.maestro/scene_push_prepare.yaml @@ -3,8 +3,11 @@ name: React Native UIScene push routing, prepare --- - launchApp: clearState: false + arguments: + CioSceneE2EMode: ${ROUTING_MODE} permissions: notifications: allow - extendedWaitUntil: - visible: "Customer.io React Native scene E2E ready" + visible: 'Customer.io React Native scene E2E ready' timeout: 15000 +- assertVisible: 'Routing: ${ROUTING_MODE}' diff --git a/README.md b/README.md index 82bbfb1d..1d86b792 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,10 @@ useEffect(() => { This SDK supports [rich push notifications](https://customer.io/docs/sdk/react-native/rich-push/) using Firebase (for Android) and either Firebase or APNs (for iOS). Follow our [push setup guide](https://customer.io/docs/sdk/react-native/push/) to configure your project for push. -On iOS, a `UIScene` host must install the wrapper's deep-link bridge before React Native starts. Call this first in `scene(_:willConnectTo:options:)`: +On iOS, a `UIScene` host using the acknowledged handler must declare that ownership before React Native starts. Call this first in `scene(_:willConnectTo:options:)` so cold destinations wait for the JavaScript handler instead of entering the legacy `Linking` path: ```swift -NativeCustomerIO.configureSceneDeepLinkRouting() +NativeCustomerIO.configureAcknowledgedSceneDeepLinkRouting() ``` Register the app's Customer.io deep-link handler before calling `CustomerIO.initialize`. Return `true` after routing a URL. Return `false` to let the native SDK try the host AppDelegate and then open the URL through the system. A thrown error, rejected promise, missing handler, or handler timeout follows the same fallback. Cold URLs wait up to ten seconds for registration and are replayed once the handler is ready. After delivery, the handler has ten seconds to settle. Return `false` only for URLs your app does not own; opening an app-owned URL through the system can deliver it back to your app lifecycle. @@ -95,7 +95,9 @@ CustomerIO.initialize(config); Remove the returned subscription when the routing owner is torn down. The Expo plugin detects its scene lifecycle during native initialization, so Expo app code does not call `configureSceneDeepLinkRouting` or `CustomerIO.initialize`. In an Expo app, call `setDeepLinkHandler` when the root routing owner starts. -The existing `Linking` readiness API remains available for backward compatibility: +The existing `Linking` path remains available for backward compatibility. Keep +`NativeCustomerIO.configureSceneDeepLinkRouting()` in your SceneDelegate, then signal readiness +after registering the listener: ```typescript const subscription = Linking.addEventListener('url', ({ url }) => { diff --git a/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift b/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift index 7b637e2a..8db0db5a 100644 --- a/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift +++ b/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift @@ -29,9 +29,29 @@ struct CustomerIOReactNativeDeepLinkRequestStore { var state: RequestState } - private var deliveryMode: DeliveryMode = .unavailable + private var isLinkingReady = false + private var requiresHandler = false + private var hasHandler = false private var requests: [Request] = [] + private var deliveryMode: DeliveryMode { + if hasHandler { + return .handler + } + if !requiresHandler, isLinkingReady { + return .linking + } + return .unavailable + } + + var requiresAcknowledgedHandler: Bool { + requiresHandler + } + + mutating func requireAcknowledgedHandler() { + requiresHandler = true + } + mutating func accept(_ url: URL) -> Acceptance { switch deliveryMode { case .unavailable: @@ -48,9 +68,9 @@ struct CustomerIOReactNativeDeepLinkRequestStore { } mutating func useLinking() -> [URL] { - guard deliveryMode != .handler else { return [] } + isLinkingReady = true + guard deliveryMode == .linking else { return [] } - deliveryMode = .linking let urls = requests.compactMap { request in request.state == .buffered ? request.url : nil } @@ -59,7 +79,7 @@ struct CustomerIOReactNativeDeepLinkRequestStore { } mutating func useHandler() -> [(UUID, URL)] { - deliveryMode = .handler + hasHandler = true var deliveries: [(UUID, URL)] = [] for index in requests.indices where requests[index].state == .buffered { requests[index].state = .awaitingAcknowledgement @@ -68,9 +88,21 @@ struct CustomerIOReactNativeDeepLinkRequestStore { return deliveries } - mutating func removeHandler() { - guard deliveryMode == .handler else { return } - deliveryMode = .unavailable + mutating func removeHandler() -> [UUID] { + guard hasHandler else { return [] } + hasHandler = false + + var replacementIds: [UUID] = [] + for index in requests.indices where requests[index].state == .awaitingAcknowledgement { + let replacementId = UUID() + requests[index] = Request( + id: replacementId, + url: requests[index].url, + state: .buffered + ) + replacementIds.append(replacementId) + } + return replacementIds } mutating func acknowledge(_ id: UUID, handled: Bool) -> Resolution? { diff --git a/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift b/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift index e8422411..eb587d3c 100644 --- a/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift +++ b/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift @@ -15,12 +15,11 @@ enum CustomerIOReactNativeDeepLinkRouter { private static var requestStore = CustomerIOReactNativeDeepLinkRequestStore() private static var handlerEmitter: ((_ id: String, _ url: String) -> Void)? private static var handlerToken: UUID? - private static var hasInstalledCallback = false - static var isInstalled: Bool { + static var requiresAcknowledgedHandler: Bool { stateLock.lock() defer { stateLock.unlock() } - return hasInstalledCallback + return requestStore.requiresAcknowledgedHandler } private static var hasSceneManifest: Bool { @@ -50,7 +49,12 @@ enum CustomerIOReactNativeDeepLinkRouter { static func install() { guard isSceneLifecycleEnabled else { return } - installCallback() + installCallback(requiringAcknowledgedHandler: false) + } + + static func installAcknowledgedHandler() { + guard isSceneLifecycleEnabled else { return } + installCallback(requiringAcknowledgedHandler: true) } /// Expo owns scene-to-Linking forwarding even on React Native versions that do not expose the @@ -64,12 +68,14 @@ enum CustomerIOReactNativeDeepLinkRouter { ) return } - installCallback() + installCallback(requiringAcknowledgedHandler: false) } - private static func installCallback() { + private static func installCallback(requiringAcknowledgedHandler: Bool) { stateLock.lock() - hasInstalledCallback = true + if requiringAcknowledgedHandler { + requestStore.requireAcknowledgedHandler() + } stateLock.unlock() DIGraphShared.shared.deepLinkUtil.setDeepLinkCallback { url in accept(url) @@ -87,9 +93,7 @@ enum CustomerIOReactNativeDeepLinkRouter { DIGraphShared.shared.logger.info( "Customer.io buffered an SDK deep link until a React Native route is ready" ) - DispatchQueue.main.asyncAfter(deadline: .now() + readinessTimeout) { - expireReadiness(id) - } + scheduleReadinessExpiration(for: id) case let .linking(url): route(url) case let .handler(id, url): @@ -136,8 +140,10 @@ enum CustomerIOReactNativeDeepLinkRouter { } handlerToken = nil handlerEmitter = nil - requestStore.removeHandler() + let replacementIds = requestStore.removeHandler() stateLock.unlock() + + replacementIds.forEach(scheduleReadinessExpiration) } static func acknowledge(_ id: String, handled: Bool) { @@ -205,6 +211,12 @@ enum CustomerIOReactNativeDeepLinkRouter { } } + private static func scheduleReadinessExpiration(for id: UUID) { + DispatchQueue.main.asyncAfter(deadline: .now() + readinessTimeout) { + expireReadiness(id) + } + } + private static func expireReadiness(_ id: UUID) { stateLock.lock() let url = requestStore.expireReadiness(id) @@ -240,6 +252,11 @@ enum CustomerIOReactNativeDeepLinkRouter { DIGraphShared.shared.logger.info( "Customer.io deep link was handled by the host AppDelegate fallback" ) + } else if isAppOwnedCustomScheme(url) { + DIGraphShared.shared.logger.error( + "Customer.io did not reopen a deep link whose custom URL scheme belongs to " + + "the host app" + ) } else { uiKit.open(url: url) DIGraphShared.shared.logger.info( @@ -253,4 +270,18 @@ enum CustomerIOReactNativeDeepLinkRouter { DispatchQueue.main.async(execute: open) } } + + private static func isAppOwnedCustomScheme(_ url: URL) -> Bool { + guard let scheme = url.scheme, + scheme.caseInsensitiveCompare("http") != .orderedSame, + scheme.caseInsensitiveCompare("https") != .orderedSame, + let urlTypes = Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") + as? [[String: Any]] + else { return false } + + return urlTypes.contains { urlType in + guard let schemes = urlType["CFBundleURLSchemes"] as? [String] else { return false } + return schemes.contains { $0.caseInsensitiveCompare(scheme) == .orderedSame } + } + } } diff --git a/ios/wrappers/NativeCustomerIO.swift b/ios/wrappers/NativeCustomerIO.swift index e2941f20..590a2633 100644 --- a/ios/wrappers/NativeCustomerIO.swift +++ b/ios/wrappers/NativeCustomerIO.swift @@ -23,6 +23,17 @@ public class NativeCustomerIO: NSObject { CustomerIOReactNativeDeepLinkRouter.install() } + /// Installs scene deep-link routing for the acknowledged JavaScript handler before the React + /// Native bridge starts. Call this at the start of `scene(_:willConnectTo:options:)` when the + /// host uses `CustomerIO.setDeepLinkHandler`. + @objc + public static func configureAcknowledgedSceneDeepLinkRouting() { + // The cold scene connection happens before JavaScript can register its handler. Record the + // host's ownership choice now so initialization cannot drain a buffered URL into Linking. + guard CustomerIO.shared.implementation == nil else { return } + CustomerIOReactNativeDeepLinkRouter.installAcknowledgedHandler() + } + /// Installs deep-link routing for an Expo-owned scene lifecycle. /// /// Expo forwards scene URLs into React Native Linking itself, including on React Native diff --git a/ios/wrappers/liveactivities/NativeLiveActivities.swift b/ios/wrappers/liveactivities/NativeLiveActivities.swift index 88f897d5..9b1434dd 100644 --- a/ios/wrappers/liveactivities/NativeLiveActivities.swift +++ b/ios/wrappers/liveactivities/NativeLiveActivities.swift @@ -246,9 +246,9 @@ public class NativeLiveActivities: NSObject { } /// Build React Native launch options from a scene connection, reporting a cold Live Activity - /// tap and routing its destination through the installed Customer.io scene router. Without an - /// installed router, the destination remains in React Native's launch options. This mirrors the - /// `RCTConvertConnectionOptionsToLaunchOptions` conversion verified in React Native + /// tap and routing its destination through the installed Customer.io scene router. Without the + /// acknowledged-handler opt-in, the destination remains in React Native's launch options. This + /// mirrors the `RCTConvertConnectionOptionsToLaunchOptions` conversion verified in React Native /// 0.88.0-nightly-20260823-0c7f63a4e. Before `CustomerIO.initialize`, the native Live Activities /// stub parses the redirect and buffers the opened metric for the module to flush at initialization. @objc(reactNativeLaunchOptionsFromConnectionOptions:) @@ -260,7 +260,7 @@ public class NativeLiveActivities: NSObject { if let url = connectionOptions.urlContexts.first?.url, let routableUrl = handleWidgetUrl(url) { - if CustomerIOReactNativeDeepLinkRouter.isInstalled { + if CustomerIOReactNativeDeepLinkRouter.requiresAcknowledgedHandler { CustomerIOReactNativeDeepLinkRouter.accept(routableUrl) } else { launchOptions[.url] = routableUrl diff --git a/scripts/test_ios_deep_link_request_store.swift b/scripts/test_ios_deep_link_request_store.swift index b1f07e39..800ad2cd 100644 --- a/scripts/test_ios_deep_link_request_store.swift +++ b/scripts/test_ios_deep_link_request_store.swift @@ -109,6 +109,45 @@ private enum DeepLinkRequestStoreTests { "explicit handler must remain able to acknowledge the URL" ) + let requiredUrl = URL(string: "myapp://required-handler")! + var requiredStore = CustomerIOReactNativeDeepLinkRequestStore() + requiredStore.requireAcknowledgedHandler() + let requiredId = bufferedRequest(requiredStore.accept(requiredUrl)) + require( + requiredStore.useLinking().isEmpty, + "native initialization must not drain a URL reserved for the acknowledged handler" + ) + let requiredReplay = requiredStore.useHandler() + require( + requiredReplay.count == 1 && requiredReplay[0].0 == requiredId, + "a handler registered after Linking readiness must receive the reserved URL" + ) + + let reloadUrl = URL(string: "myapp://bridge-reload")! + var reloadStore = CustomerIOReactNativeDeepLinkRequestStore() + _ = reloadStore.useLinking() + _ = reloadStore.useHandler() + let oldReloadId = handlerRequest(reloadStore.accept(reloadUrl)) + let replacementIds = reloadStore.removeHandler() + require(replacementIds.count == 1, "handler removal must rebuffer its in-flight URL") + require( + reloadStore.accept(linkingUrl) == .linking(linkingUrl), + "handler removal must restore previously signalled Linking readiness" + ) + let reloadReplay = reloadStore.useHandler() + require( + reloadReplay.count == 1 && reloadReplay[0].0 == replacementIds[0], + "a replacement handler must receive the rebuffered URL" + ) + require( + reloadStore.acknowledge(oldReloadId, handled: true) == nil, + "an acknowledgement from the invalidated bridge must not resolve the replacement" + ) + require( + reloadStore.acknowledge(replacementIds[0], handled: true) == .handled, + "the replacement handler must resolve the rebuffered URL" + ) + print("React Native deep-link request store tests passed") } } diff --git a/src/customerio-cdp.ts b/src/customerio-cdp.ts index ddab9f58..ce60d7c7 100644 --- a/src/customerio-cdp.ts +++ b/src/customerio-cdp.ts @@ -85,9 +85,11 @@ export class CustomerIO { /** * Register an acknowledged deep-link handler for an iOS UIScene host. * - * Register this before `initialize`. Return `true` after routing a URL. Returning `false`, - * throwing, or rejecting lets the native SDK try the host AppDelegate and then the system. - * Cold URLs are buffered until this handler is registered, subject to the native timeout. + * In a native React Native scene host, pair this with + * `NativeCustomerIO.configureAcknowledgedSceneDeepLinkRouting()` in the SceneDelegate. Register + * this before `initialize` when possible. Return `true` after routing a URL. Returning `false`, + * throwing, or rejecting lets the native SDK try the host AppDelegate and then the system. Cold + * URLs are buffered until this handler is registered, subject to the native timeout. */ static readonly setDeepLinkHandler = ( handler: DeepLinkHandler From 32d09da6d7ba7725ea006c6537b917d6c5cb9d30 Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Fri, 28 Aug 2026 01:13:27 -0400 Subject: [PATCH 3/6] fix: preserve ordinary scene link routing --- .github/workflows/react-native-scene-e2e.yml | 1 + .maestro/fixtures/react-native-scene/App.tsx | 20 +++++++---- .maestro/run_scene_push.sh | 20 ++++++++++- .maestro/scene_push_declined.yaml | 1 + .maestro/scene_url_open.yaml | 11 ++++++ README.md | 13 ++++--- ...merIOReactNativeDeepLinkRequestStore.swift | 34 ++++++++++++++---- .../CustomerIOReactNativeDeepLinkRouter.swift | 34 ++++++++++++++---- .../liveactivities/NativeLiveActivities.swift | 14 +++++--- .../test_ios_deep_link_request_store.swift | 36 +++++++++++++------ 10 files changed, 146 insertions(+), 38 deletions(-) create mode 100644 .maestro/scene_url_open.yaml diff --git a/.github/workflows/react-native-scene-e2e.yml b/.github/workflows/react-native-scene-e2e.yml index 24624d52..6bf4db17 100644 --- a/.github/workflows/react-native-scene-e2e.yml +++ b/.github/workflows/react-native-scene-e2e.yml @@ -13,6 +13,7 @@ on: - '.maestro/scene_push_declined.yaml' - '.maestro/scene_push_prepare.yaml' - '.maestro/scene_push_warm.yaml' + - '.maestro/scene_url_open.yaml' - 'src/customerio-cdp.ts' - 'src/customerio-push.ts' - 'src/index.ts' diff --git a/.maestro/fixtures/react-native-scene/App.tsx b/.maestro/fixtures/react-native-scene/App.tsx index 48102015..97361b95 100644 --- a/.maestro/fixtures/react-native-scene/App.tsx +++ b/.maestro/fixtures/react-native-scene/App.tsx @@ -36,13 +36,21 @@ export default function App(): React.JSX.Element { useEffect(() => { let active = true; - let subscription: { remove(): void } | undefined; + const subscriptions: Array<{ remove(): void }> = []; const initialize = async () => { - if (routingMode === 'linking') { - subscription = Linking.addEventListener('url', ({ url }) => { + subscriptions.push( + Linking.addEventListener('url', ({ url }) => { setReceivedUrl(url); - }); + }) + ); + + const initialUrl = await Linking.getInitialURL(); + if (initialUrl) { + setReceivedUrl(initialUrl); + } + + if (routingMode === 'linking') { CustomerIO.setDeepLinkRoutingReady(); } @@ -66,7 +74,7 @@ export default function App(): React.JSX.Element { handlerSubscription.remove(); return; } - subscription = handlerSubscription; + subscriptions.push(handlerSubscription); } const status = @@ -88,7 +96,7 @@ export default function App(): React.JSX.Element { return () => { active = false; - subscription?.remove(); + subscriptions.forEach((subscription) => subscription.remove()); }; }, [routingMode]); diff --git a/.maestro/run_scene_push.sh b/.maestro/run_scene_push.sh index 9b913d3d..91a10fb8 100755 --- a/.maestro/run_scene_push.sh +++ b/.maestro/run_scene_push.sh @@ -110,7 +110,7 @@ cleanup() { else echo "**Classification:** ${current_phase}-failed" fi - echo '**Scope:** simulator notification presentation, tap, and exact acknowledged-handler and legacy-Linking destinations; no backend delivery or attribution claim' + echo '**Scope:** simulator notification presentation, tap, ordinary cold URL, and exact acknowledged-handler and legacy-Linking destinations; no backend delivery or attribution claim' } >> "$GITHUB_STEP_SUMMARY" fi return "$exit_code" @@ -282,6 +282,24 @@ for routing_mode in acknowledged linking; do fi maestro "${prepare_args[@]}" xcrun simctl terminate "$device_id" "$APP_ID" + + if [[ "$routing_mode" == acknowledged ]]; then + current_phase="ordinary-link-$routing_mode" + xcrun simctl openurl "$device_id" 'cio-rn-scene-e2e://ordinary-cold' + ordinary_args=(--device "$device_id" test .maestro/scene_url_open.yaml) + if [[ -n "${RUNNER_TEMP:-}" ]]; then + ordinary_args=( + --device "$device_id" + test + --debug-output "$RUNNER_TEMP/react-native-scene-maestro-scene_url_open-$routing_mode" + --flatten-debug-output + .maestro/scene_url_open.yaml + ) + fi + maestro "${ordinary_args[@]}" + xcrun simctl terminate "$device_id" "$APP_ID" + fi + current_phase="routing-$routing_mode" run_notification_flow .maestro/scene_push_open.yaml .maestro/fixtures/customerio_scene_cold.apns run_notification_flow .maestro/scene_push_warm.yaml .maestro/fixtures/customerio_scene_warm.apns diff --git a/.maestro/scene_push_declined.yaml b/.maestro/scene_push_declined.yaml index 34c80b94..14009005 100644 --- a/.maestro/scene_push_declined.yaml +++ b/.maestro/scene_push_declined.yaml @@ -12,4 +12,5 @@ name: React Native UIScene declined app-owned URL - extendedWaitUntil: visible: 'Declined count: 1' timeout: 15000 +- assertVisible: 'Received: cio-rn-scene-e2e://declined' - assertNotVisible: 'Declined count: 2' diff --git a/.maestro/scene_url_open.yaml b/.maestro/scene_url_open.yaml new file mode 100644 index 00000000..452bdc15 --- /dev/null +++ b/.maestro/scene_url_open.yaml @@ -0,0 +1,11 @@ +appId: org.reactjs.native.example.CioRnSceneHost +name: React Native UIScene ordinary cold URL +--- +- tapOn: + text: Open + optional: true +- extendedWaitUntil: + visible: 'Customer.io React Native scene E2E ready' + timeout: 15000 +- assertVisible: 'Routing: acknowledged' +- assertVisible: 'Received: cio-rn-scene-e2e://ordinary-cold' diff --git a/README.md b/README.md index 1d86b792..543fc6f8 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ On iOS, a `UIScene` host using the acknowledged handler must declare that owners NativeCustomerIO.configureAcknowledgedSceneDeepLinkRouting() ``` -Register the app's Customer.io deep-link handler before calling `CustomerIO.initialize`. Return `true` after routing a URL. Return `false` to let the native SDK try the host AppDelegate and then open the URL through the system. A thrown error, rejected promise, missing handler, or handler timeout follows the same fallback. Cold URLs wait up to ten seconds for registration and are replayed once the handler is ready. After delivery, the handler has ten seconds to settle. Return `false` only for URLs your app does not own; opening an app-owned URL through the system can deliver it back to your app lifecycle. +Register the app's Customer.io deep-link handler before calling `CustomerIO.initialize`. Return `true` after routing a URL. Return `false` to let the native SDK try the host AppDelegate, then return a host-owned custom scheme to React Native `Linking` or open other URLs through the system. A thrown error, rejected promise, missing handler, or handler timeout follows the same fallback. Cold URLs wait up to ten seconds for registration and are replayed once the handler is ready. After delivery, the handler has ten seconds to settle. ```typescript const subscription = CustomerIO.setDeepLinkHandler(async (url) => { @@ -93,7 +93,7 @@ const subscription = CustomerIO.setDeepLinkHandler(async (url) => { CustomerIO.initialize(config); ``` -Remove the returned subscription when the routing owner is torn down. The Expo plugin detects its scene lifecycle during native initialization, so Expo app code does not call `configureSceneDeepLinkRouting` or `CustomerIO.initialize`. In an Expo app, call `setDeepLinkHandler` when the root routing owner starts. +Remove the returned subscription when the routing owner is torn down. The existing `Linking` path remains available for backward compatibility. Keep `NativeCustomerIO.configureSceneDeepLinkRouting()` in your SceneDelegate, then signal readiness @@ -108,6 +108,11 @@ CustomerIO.setDeepLinkRoutingReady(); This older path cannot acknowledge whether JavaScript handled the URL, so the SDK cannot safely fall back after it publishes a `Linking` event. Prefer `setDeepLinkHandler` for new UIScene integrations. Older React Native versions and AppDelegate-only hosts keep their existing deep-link integration. +Expo apps keep the `Linking` path. Register the app's `Linking` listener, then call +`CustomerIO.setDeepLinkRoutingReady()` after the router is ready. The Expo plugin configures its +native scene lifecycle automatically, and Expo app code does not call either native configuration +method or `CustomerIO.initialize`. + This integration applies after the host has adopted React Native's UIScene lifecycle; the plugin does not replace React Native's root application lifecycle. This release's compatibility scope is one simultaneous window scene. Multiple simultaneous React Native window scenes are not supported. React Native's URL notification is process-wide, so SDK-published destinations may reach every connected React Native instance rather than one selected window. @@ -143,7 +148,7 @@ extension AppDelegate { A React Native `AppDelegate` conforms to `UIApplicationDelegate` directly rather than subclassing, so this method is not an `override`, and the URL is passed on to `RCTLinkingManager` instead of `super`. See [the sample app's `AppDelegate.swift`](/example/ios/SampleApp/AppDelegate.swift) for this in context. -For a `UIScene` host, handle both lifecycle paths. At scene connection, pass the connection options through the wrapper so a cold Live Activity tap is attributed and its destination enters the same acknowledged scene router as other Customer.io deep links: +For a `UIScene` host, handle both lifecycle paths. At scene connection, pass the connection options through the wrapper so a cold Live Activity tap is attributed and its destination enters the acknowledged Customer.io router. Ordinary app links remain in React Native launch options for `Linking`: ```swift import customerio_reactnative @@ -155,7 +160,7 @@ reactNativeFactory?.startReactNative( ) ``` -Then replace the ordinary React Native URL-forwarding body in the existing `SceneDelegate` for warm opens. This reports Live Activity taps and routes each remaining URL through the same scene router: +Then replace the ordinary React Native URL-forwarding body in the existing `SceneDelegate` for warm opens. This reports Live Activity taps, sends Customer.io destinations through the configured Customer.io router, and forwards ordinary app links to React Native `Linking`: ```swift import customerio_reactnative diff --git a/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift b/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift index 8db0db5a..1b2942df 100644 --- a/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift +++ b/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift @@ -12,6 +12,11 @@ struct CustomerIOReactNativeDeepLinkRequestStore { case fallback(URL) } + enum HandlerRemoval: Equatable { + case buffered([UUID]) + case linking([URL]) + } + private enum DeliveryMode: Equatable { case unavailable case linking @@ -78,18 +83,28 @@ struct CustomerIOReactNativeDeepLinkRequestStore { return urls } - mutating func useHandler() -> [(UUID, URL)] { + mutating func useHandler(replacingExisting: Bool = false) -> [(UUID, URL)] { hasHandler = true var deliveries: [(UUID, URL)] = [] - for index in requests.indices where requests[index].state == .buffered { - requests[index].state = .awaitingAcknowledgement + for index in requests.indices + where requests[index].state == .buffered || + (replacingExisting && requests[index].state == .awaitingAcknowledgement) { + if replacingExisting, requests[index].state == .awaitingAcknowledgement { + requests[index] = Request( + id: UUID(), + url: requests[index].url, + state: .awaitingAcknowledgement + ) + } else { + requests[index].state = .awaitingAcknowledgement + } deliveries.append((requests[index].id, requests[index].url)) } return deliveries } - mutating func removeHandler() -> [UUID] { - guard hasHandler else { return [] } + mutating func removeHandler() -> HandlerRemoval { + guard hasHandler else { return .buffered([]) } hasHandler = false var replacementIds: [UUID] = [] @@ -102,7 +117,14 @@ struct CustomerIOReactNativeDeepLinkRequestStore { ) replacementIds.append(replacementId) } - return replacementIds + + guard deliveryMode == .linking else { return .buffered(replacementIds) } + + let urls = requests.compactMap { request in + request.state == .buffered ? request.url : nil + } + requests.removeAll { $0.state == .buffered } + return .linking(urls) } mutating func acknowledge(_ id: UUID, handled: Bool) -> Resolution? { diff --git a/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift b/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift index eb587d3c..15595f19 100644 --- a/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift +++ b/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift @@ -53,7 +53,13 @@ enum CustomerIOReactNativeDeepLinkRouter { } static func installAcknowledgedHandler() { - guard isSceneLifecycleEnabled else { return } + guard isSceneLifecycleEnabled else { + DIGraphShared.shared.logger.error( + "Customer.io could not install acknowledged deep-link routing because the host " + + "does not expose a supported React Native scene lifecycle" + ) + return + } installCallback(requiringAcknowledgedHandler: true) } @@ -121,11 +127,19 @@ enum CustomerIOReactNativeDeepLinkRouter { ) -> UUID { let token = UUID() stateLock.lock() + let replacesExistingHandler = handlerToken != nil + let hasAcknowledgedHandlerConfiguration = requestStore.requiresAcknowledgedHandler handlerToken = token handlerEmitter = emitter - let deliveries = requestStore.useHandler() + let deliveries = requestStore.useHandler(replacingExisting: replacesExistingHandler) stateLock.unlock() + if !hasAcknowledgedHandlerConfiguration { + DIGraphShared.shared.logger.error( + "Customer.io registered an acknowledged deep-link handler without configuring " + + "acknowledged scene routing before React Native started" + ) + } for (id, url) in deliveries { publishToHandler(id: id, url: url) } @@ -140,10 +154,15 @@ enum CustomerIOReactNativeDeepLinkRouter { } handlerToken = nil handlerEmitter = nil - let replacementIds = requestStore.removeHandler() + let removal = requestStore.removeHandler() stateLock.unlock() - replacementIds.forEach(scheduleReadinessExpiration) + switch removal { + case let .buffered(replacementIds): + replacementIds.forEach(scheduleReadinessExpiration) + case let .linking(urls): + urls.forEach(route) + } } static func acknowledge(_ id: String, handled: Bool) { @@ -253,9 +272,10 @@ enum CustomerIOReactNativeDeepLinkRouter { "Customer.io deep link was handled by the host AppDelegate fallback" ) } else if isAppOwnedCustomScheme(url) { - DIGraphShared.shared.logger.error( - "Customer.io did not reopen a deep link whose custom URL scheme belongs to " + - "the host app" + route(url) + DIGraphShared.shared.logger.info( + "Customer.io forwarded the host app's custom-scheme deep link to React " + + "Native Linking" ) } else { uiKit.open(url: url) diff --git a/ios/wrappers/liveactivities/NativeLiveActivities.swift b/ios/wrappers/liveactivities/NativeLiveActivities.swift index 9b1434dd..475d650f 100644 --- a/ios/wrappers/liveactivities/NativeLiveActivities.swift +++ b/ios/wrappers/liveactivities/NativeLiveActivities.swift @@ -237,12 +237,16 @@ public class NativeLiveActivities: NSObject { CustomerIO.liveActivities.handleWidgetUrl(url) } - /// Report an `opened` metric for a tapped Live Activity and route its destination through - /// React Native Linking. Call this from a scene-based host's URL lifecycle method. + /// Report an `opened` metric for a tapped Live Activity and route its destination through the + /// configured Customer.io router. Ordinary URLs continue through React Native Linking. @objc(handleAndRouteWidgetUrl:) public static func handleAndRouteWidgetUrl(_ url: URL) { guard let routableUrl = handleWidgetUrl(url) else { return } - CustomerIOReactNativeDeepLinkRouter.accept(routableUrl) + if routableUrl == url { + CustomerIOReactNativeDeepLinkRouter.route(routableUrl) + } else { + CustomerIOReactNativeDeepLinkRouter.accept(routableUrl) + } } /// Build React Native launch options from a scene connection, reporting a cold Live Activity @@ -260,7 +264,9 @@ public class NativeLiveActivities: NSObject { if let url = connectionOptions.urlContexts.first?.url, let routableUrl = handleWidgetUrl(url) { - if CustomerIOReactNativeDeepLinkRouter.requiresAcknowledgedHandler { + if CustomerIOReactNativeDeepLinkRouter.requiresAcknowledgedHandler, + routableUrl != url + { CustomerIOReactNativeDeepLinkRouter.accept(routableUrl) } else { launchOptions[.url] = routableUrl diff --git a/scripts/test_ios_deep_link_request_store.swift b/scripts/test_ios_deep_link_request_store.swift index 800ad2cd..10f049ed 100644 --- a/scripts/test_ios_deep_link_request_store.swift +++ b/scripts/test_ios_deep_link_request_store.swift @@ -128,24 +128,40 @@ private enum DeepLinkRequestStoreTests { _ = reloadStore.useLinking() _ = reloadStore.useHandler() let oldReloadId = handlerRequest(reloadStore.accept(reloadUrl)) - let replacementIds = reloadStore.removeHandler() - require(replacementIds.count == 1, "handler removal must rebuffer its in-flight URL") + let reloadRemoval = reloadStore.removeHandler() + guard case let .linking(reloadUrls) = reloadRemoval else { + fputs("error: handler removal must use previously signalled Linking readiness\n", stderr) + exit(1) + } require( - reloadStore.accept(linkingUrl) == .linking(linkingUrl), - "handler removal must restore previously signalled Linking readiness" + reloadUrls == [reloadUrl], + "handler removal must immediately return its in-flight URL to ready Linking" ) - let reloadReplay = reloadStore.useHandler() require( - reloadReplay.count == 1 && reloadReplay[0].0 == replacementIds[0], - "a replacement handler must receive the rebuffered URL" + reloadStore.accept(linkingUrl) == .linking(linkingUrl), + "handler removal must restore previously signalled Linking readiness for new URLs" ) require( reloadStore.acknowledge(oldReloadId, handled: true) == nil, - "an acknowledgement from the invalidated bridge must not resolve the replacement" + "an acknowledgement from the removed handler must not resolve a Linking delivery" + ) + + let replacementUrl = URL(string: "myapp://handler-replacement")! + var replacementStore = CustomerIOReactNativeDeepLinkRequestStore() + _ = replacementStore.useHandler() + let oldReplacementId = handlerRequest(replacementStore.accept(replacementUrl)) + let replacementReplay = replacementStore.useHandler(replacingExisting: true) + require( + replacementReplay.count == 1 && replacementReplay[0].0 != oldReplacementId, + "a replacement handler must receive the in-flight URL with a new request ID" + ) + require( + replacementStore.acknowledge(oldReplacementId, handled: true) == nil, + "an acknowledgement from the replaced handler must not resolve the new request" ) require( - reloadStore.acknowledge(replacementIds[0], handled: true) == .handled, - "the replacement handler must resolve the rebuffered URL" + replacementStore.acknowledge(replacementReplay[0].0, handled: true) == .handled, + "the replacement handler must resolve the replayed URL" ) print("React Native deep-link request store tests passed") From cb3bf86af913deb57adf3b2607c32717d789dbed Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Fri, 28 Aug 2026 01:46:42 -0400 Subject: [PATCH 4/6] fix: preserve in-flight deep-link acknowledgements --- .github/workflows/test.yml | 3 ++ .maestro/run_scene_push.sh | 46 ++++++++++++------ .maestro/scene_url_open.yaml | 4 +- README.md | 11 +++-- ...merIOReactNativeDeepLinkRequestStore.swift | 47 ++++--------------- .../CustomerIOReactNativeDeepLinkRouter.swift | 30 ++++++------ .../test_ios_deep_link_request_store.swift | 30 ++++-------- 7 files changed, 76 insertions(+), 95 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 314f180c..5a1df8c0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,9 @@ jobs: - name: Compile run: npm run typescript + - name: Unit tests + run: npm test -- --runInBand --no-watchman + # `npm pack`, not `npm publish --dry-run`. From npm 11 (which node 24 bundles) a dry-run # publish also runs the registry-side preflight, so it fails with "cannot publish over the # previously published versions" on any branch whose version semantic-release has not bumped diff --git a/.maestro/run_scene_push.sh b/.maestro/run_scene_push.sh index 91a10fb8..6074a7ed 100755 --- a/.maestro/run_scene_push.sh +++ b/.maestro/run_scene_push.sh @@ -110,7 +110,7 @@ cleanup() { else echo "**Classification:** ${current_phase}-failed" fi - echo '**Scope:** simulator notification presentation, tap, ordinary cold URL, and exact acknowledged-handler and legacy-Linking destinations; no backend delivery or attribution claim' + echo '**Scope:** simulator notification presentation, tap, ordinary warm and cold URLs, and exact acknowledged-handler and legacy-Linking destinations; no backend delivery or attribution claim' } >> "$GITHUB_STEP_SUMMARY" fi return "$exit_code" @@ -201,6 +201,30 @@ run_notification_flow() { flow_log="" } +run_ordinary_url_flow() { + local url="$1" + local state="$2" + local ordinary_args=( + --device "$device_id" + test + -e "EXPECTED_URL=$url" + .maestro/scene_url_open.yaml + ) + + xcrun simctl openurl "$device_id" "$url" + if [[ -n "${RUNNER_TEMP:-}" ]]; then + ordinary_args=( + --device "$device_id" + test + --debug-output "$RUNNER_TEMP/react-native-scene-maestro-scene_url_open-$state" + --flatten-debug-output + -e "EXPECTED_URL=$url" + .maestro/scene_url_open.yaml + ) + fi + maestro "${ordinary_args[@]}" +} + cd "$REPO_ROOT" current_phase="package" npm ci @@ -281,24 +305,16 @@ for routing_mode in acknowledged linking; do ) fi maestro "${prepare_args[@]}" - xcrun simctl terminate "$device_id" "$APP_ID" if [[ "$routing_mode" == acknowledged ]]; then - current_phase="ordinary-link-$routing_mode" - xcrun simctl openurl "$device_id" 'cio-rn-scene-e2e://ordinary-cold' - ordinary_args=(--device "$device_id" test .maestro/scene_url_open.yaml) - if [[ -n "${RUNNER_TEMP:-}" ]]; then - ordinary_args=( - --device "$device_id" - test - --debug-output "$RUNNER_TEMP/react-native-scene-maestro-scene_url_open-$routing_mode" - --flatten-debug-output - .maestro/scene_url_open.yaml - ) - fi - maestro "${ordinary_args[@]}" + current_phase="ordinary-link-warm-$routing_mode" + run_ordinary_url_flow 'cio-rn-scene-e2e://ordinary-warm' warm xcrun simctl terminate "$device_id" "$APP_ID" + + current_phase="ordinary-link-cold-$routing_mode" + run_ordinary_url_flow 'cio-rn-scene-e2e://ordinary-cold' cold fi + xcrun simctl terminate "$device_id" "$APP_ID" current_phase="routing-$routing_mode" run_notification_flow .maestro/scene_push_open.yaml .maestro/fixtures/customerio_scene_cold.apns diff --git a/.maestro/scene_url_open.yaml b/.maestro/scene_url_open.yaml index 452bdc15..00e32a64 100644 --- a/.maestro/scene_url_open.yaml +++ b/.maestro/scene_url_open.yaml @@ -1,5 +1,5 @@ appId: org.reactjs.native.example.CioRnSceneHost -name: React Native UIScene ordinary cold URL +name: React Native UIScene ordinary URL --- - tapOn: text: Open @@ -8,4 +8,4 @@ name: React Native UIScene ordinary cold URL visible: 'Customer.io React Native scene E2E ready' timeout: 15000 - assertVisible: 'Routing: acknowledged' -- assertVisible: 'Received: cio-rn-scene-e2e://ordinary-cold' +- assertVisible: 'Received: ${EXPECTED_URL}' diff --git a/README.md b/README.md index 543fc6f8..e74ebfc4 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ useEffect(() => { This SDK supports [rich push notifications](https://customer.io/docs/sdk/react-native/rich-push/) using Firebase (for Android) and either Firebase or APNs (for iOS). Follow our [push setup guide](https://customer.io/docs/sdk/react-native/push/) to configure your project for push. -On iOS, a `UIScene` host using the acknowledged handler must declare that ownership before React Native starts. Call this first in `scene(_:willConnectTo:options:)` so cold destinations wait for the JavaScript handler instead of entering the legacy `Linking` path: +On iOS, a React Native 0.88+ `UIScene` host using the acknowledged handler must declare that ownership before React Native starts. This API is not used on Android or by AppDelegate-only hosts. Call this first in `scene(_:willConnectTo:options:)` so cold destinations wait for the JavaScript handler instead of entering the legacy `Linking` path: ```swift NativeCustomerIO.configureAcknowledgedSceneDeepLinkRouting() @@ -108,10 +108,11 @@ CustomerIO.setDeepLinkRoutingReady(); This older path cannot acknowledge whether JavaScript handled the URL, so the SDK cannot safely fall back after it publishes a `Linking` event. Prefer `setDeepLinkHandler` for new UIScene integrations. Older React Native versions and AppDelegate-only hosts keep their existing deep-link integration. -Expo apps keep the `Linking` path. Register the app's `Linking` listener, then call -`CustomerIO.setDeepLinkRoutingReady()` after the router is ready. The Expo plugin configures its -native scene lifecycle automatically, and Expo app code does not call either native configuration -method or `CustomerIO.initialize`. +Expo apps keep the `Linking` path. With config-plugin auto-initialization, register the app's +`Linking` listener, then call `CustomerIO.setDeepLinkRoutingReady()` after the router is ready. The +Expo plugin configures its native scene lifecycle automatically, so Expo app code does not call a +native configuration method. Apps using JavaScript initialization register the router before +`CustomerIO.initialize`; initialization marks Linking ready automatically. 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/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift b/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift index 1b2942df..3169df31 100644 --- a/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift +++ b/ios/wrappers/CustomerIOReactNativeDeepLinkRequestStore.swift @@ -12,11 +12,6 @@ struct CustomerIOReactNativeDeepLinkRequestStore { case fallback(URL) } - enum HandlerRemoval: Equatable { - case buffered([UUID]) - case linking([URL]) - } - private enum DeliveryMode: Equatable { case unavailable case linking @@ -53,6 +48,10 @@ struct CustomerIOReactNativeDeepLinkRequestStore { requiresHandler } + var canDeliverWithLinking: Bool { + isLinkingReady + } + mutating func requireAcknowledgedHandler() { requiresHandler = true } @@ -83,48 +82,18 @@ struct CustomerIOReactNativeDeepLinkRequestStore { return urls } - mutating func useHandler(replacingExisting: Bool = false) -> [(UUID, URL)] { + mutating func useHandler() -> [(UUID, URL)] { hasHandler = true var deliveries: [(UUID, URL)] = [] - for index in requests.indices - where requests[index].state == .buffered || - (replacingExisting && requests[index].state == .awaitingAcknowledgement) { - if replacingExisting, requests[index].state == .awaitingAcknowledgement { - requests[index] = Request( - id: UUID(), - url: requests[index].url, - state: .awaitingAcknowledgement - ) - } else { - requests[index].state = .awaitingAcknowledgement - } + for index in requests.indices where requests[index].state == .buffered { + requests[index].state = .awaitingAcknowledgement deliveries.append((requests[index].id, requests[index].url)) } return deliveries } - mutating func removeHandler() -> HandlerRemoval { - guard hasHandler else { return .buffered([]) } + mutating func removeHandler() { hasHandler = false - - var replacementIds: [UUID] = [] - for index in requests.indices where requests[index].state == .awaitingAcknowledgement { - let replacementId = UUID() - requests[index] = Request( - id: replacementId, - url: requests[index].url, - state: .buffered - ) - replacementIds.append(replacementId) - } - - guard deliveryMode == .linking else { return .buffered(replacementIds) } - - let urls = requests.compactMap { request in - request.state == .buffered ? request.url : nil - } - requests.removeAll { $0.state == .buffered } - return .linking(urls) } mutating func acknowledge(_ id: UUID, handled: Bool) -> Resolution? { diff --git a/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift b/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift index 15595f19..4c06071c 100644 --- a/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift +++ b/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift @@ -127,11 +127,10 @@ enum CustomerIOReactNativeDeepLinkRouter { ) -> UUID { let token = UUID() stateLock.lock() - let replacesExistingHandler = handlerToken != nil let hasAcknowledgedHandlerConfiguration = requestStore.requiresAcknowledgedHandler handlerToken = token handlerEmitter = emitter - let deliveries = requestStore.useHandler(replacingExisting: replacesExistingHandler) + let deliveries = requestStore.useHandler() stateLock.unlock() if !hasAcknowledgedHandlerConfiguration { @@ -154,15 +153,8 @@ enum CustomerIOReactNativeDeepLinkRouter { } handlerToken = nil handlerEmitter = nil - let removal = requestStore.removeHandler() + requestStore.removeHandler() stateLock.unlock() - - switch removal { - case let .buffered(replacementIds): - replacementIds.forEach(scheduleReadinessExpiration) - case let .linking(urls): - urls.forEach(route) - } } static func acknowledge(_ id: String, handled: Bool) { @@ -272,11 +264,21 @@ enum CustomerIOReactNativeDeepLinkRouter { "Customer.io deep link was handled by the host AppDelegate fallback" ) } else if isAppOwnedCustomScheme(url) { + stateLock.lock() + let linkingIsReady = requestStore.canDeliverWithLinking + stateLock.unlock() route(url) - DIGraphShared.shared.logger.info( - "Customer.io forwarded the host app's custom-scheme deep link to React " + - "Native Linking" - ) + if linkingIsReady { + DIGraphShared.shared.logger.info( + "Customer.io forwarded the host app's custom-scheme deep link to React " + + "Native Linking" + ) + } else { + DIGraphShared.shared.logger.error( + "Customer.io published the host app's custom-scheme deep link before " + + "React Native Linking was marked ready; the destination may not be handled" + ) + } } else { uiKit.open(url: url) DIGraphShared.shared.logger.info( diff --git a/scripts/test_ios_deep_link_request_store.swift b/scripts/test_ios_deep_link_request_store.swift index 10f049ed..06c8a64b 100644 --- a/scripts/test_ios_deep_link_request_store.swift +++ b/scripts/test_ios_deep_link_request_store.swift @@ -128,40 +128,30 @@ private enum DeepLinkRequestStoreTests { _ = reloadStore.useLinking() _ = reloadStore.useHandler() let oldReloadId = handlerRequest(reloadStore.accept(reloadUrl)) - let reloadRemoval = reloadStore.removeHandler() - guard case let .linking(reloadUrls) = reloadRemoval else { - fputs("error: handler removal must use previously signalled Linking readiness\n", stderr) - exit(1) - } + reloadStore.removeHandler() require( - reloadUrls == [reloadUrl], - "handler removal must immediately return its in-flight URL to ready Linking" + reloadStore.acknowledge(oldReloadId, handled: true) == .handled, + "handler removal must preserve an in-flight acknowledgement" ) require( reloadStore.accept(linkingUrl) == .linking(linkingUrl), "handler removal must restore previously signalled Linking readiness for new URLs" ) - require( - reloadStore.acknowledge(oldReloadId, handled: true) == nil, - "an acknowledgement from the removed handler must not resolve a Linking delivery" - ) let replacementUrl = URL(string: "myapp://handler-replacement")! var replacementStore = CustomerIOReactNativeDeepLinkRequestStore() + replacementStore.requireAcknowledgedHandler() _ = replacementStore.useHandler() let oldReplacementId = handlerRequest(replacementStore.accept(replacementUrl)) - let replacementReplay = replacementStore.useHandler(replacingExisting: true) - require( - replacementReplay.count == 1 && replacementReplay[0].0 != oldReplacementId, - "a replacement handler must receive the in-flight URL with a new request ID" - ) + replacementStore.removeHandler() + let replacementReplay = replacementStore.useHandler() require( - replacementStore.acknowledge(oldReplacementId, handled: true) == nil, - "an acknowledgement from the replaced handler must not resolve the new request" + replacementReplay.isEmpty, + "a replacement handler must not receive a URL still awaiting its original acknowledgement" ) require( - replacementStore.acknowledge(replacementReplay[0].0, handled: true) == .handled, - "the replacement handler must resolve the replayed URL" + replacementStore.acknowledge(oldReplacementId, handled: true) == .handled, + "the original handler must be able to resolve its in-flight URL after replacement" ) print("React Native deep-link request store tests passed") From 874165d06050dfae45741f6bba860b94cbb8b2ee Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Fri, 28 Aug 2026 02:03:25 -0400 Subject: [PATCH 5/6] docs: clarify acknowledged handler fallbacks --- README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e74ebfc4..0ae1d189 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,14 @@ On iOS, a React Native 0.88+ `UIScene` host using the acknowledged handler must NativeCustomerIO.configureAcknowledgedSceneDeepLinkRouting() ``` -Register the app's Customer.io deep-link handler before calling `CustomerIO.initialize`. Return `true` after routing a URL. Return `false` to let the native SDK try the host AppDelegate, then return a host-owned custom scheme to React Native `Linking` or open other URLs through the system. A thrown error, rejected promise, missing handler, or handler timeout follows the same fallback. Cold URLs wait up to ten seconds for registration and are replayed once the handler is ready. After delivery, the handler has ten seconds to settle. +Register the app's Customer.io deep-link handler before calling `CustomerIO.initialize`. Return +`true` after routing a URL. Return `false` to let the native SDK try the host AppDelegate, then pass +a host-owned custom scheme to React Native `Linking` or open other URLs through the system. If your +handler might decline an app-owned custom scheme, keep a `Linking` URL listener registered for that +fallback. A thrown error, rejected promise, missing handler, or handler timeout follows the same +native fallback. Cold URLs wait up to ten seconds for registration and are replayed once the handler +is ready. After delivery, the handler has ten seconds to settle; after that, fallback runs and a late +result is ignored. ```typescript const subscription = CustomerIO.setDeepLinkHandler(async (url) => { @@ -93,7 +100,8 @@ const subscription = CustomerIO.setDeepLinkHandler(async (url) => { CustomerIO.initialize(config); ``` -Remove the returned subscription when the routing owner is torn down. +Remove the returned subscription when the routing owner is torn down. If no replacement handler +registers, later destinations wait for the readiness timeout and then use the native fallback. The existing `Linking` path remains available for backward compatibility. Keep `NativeCustomerIO.configureSceneDeepLinkRouting()` in your SceneDelegate, then signal readiness From 835bef6687a9a623ebe2cd2bbc29f3f498c1e873 Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Fri, 28 Aug 2026 15:22:07 -0400 Subject: [PATCH 6/6] docs: clarify deep-link fallback contracts --- README.md | 9 +++++++-- ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift | 5 ++++- ios/wrappers/NativeCustomerIO.mm | 2 ++ ios/wrappers/NativeCustomerIO.swift | 2 ++ src/customerio-cdp.ts | 10 ++++++++-- 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0ae1d189..43986d5c 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,9 @@ a host-owned custom scheme to React Native `Linking` or open other URLs through handler might decline an app-owned custom scheme, keep a `Linking` URL listener registered for that fallback. A thrown error, rejected promise, missing handler, or handler timeout follows the same native fallback. Cold URLs wait up to ten seconds for registration and are replayed once the handler -is ready. After delivery, the handler has ten seconds to settle; after that, fallback runs and a late -result is ignored. +is ready. After delivery, the handler has ten seconds to settle. If it takes longer, native fallback +runs; a handler that later routes the URL can cause a second navigation because its acknowledgement +cannot cancel the fallback. ```typescript const subscription = CustomerIO.setDeepLinkHandler(async (url) => { @@ -114,6 +115,10 @@ const subscription = Linking.addEventListener('url', ({ url }) => { CustomerIO.setDeepLinkRoutingReady(); ``` +Cold destinations wait up to ten seconds for this readiness signal. If it does not arrive, the SDK +tries the host AppDelegate, passes a host-owned custom scheme to React Native `Linking`, or opens +other URLs through the system. + This older path cannot acknowledge whether JavaScript handled the URL, so the SDK cannot safely fall back after it publishes a `Linking` event. Prefer `setDeepLinkHandler` for new UIScene integrations. Older React Native versions and AppDelegate-only hosts keep their existing deep-link integration. Expo apps keep the `Linking` path. With config-plugin auto-initialization, register the app's diff --git a/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift b/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift index 4c06071c..38689700 100644 --- a/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift +++ b/ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift @@ -180,7 +180,10 @@ enum CustomerIOReactNativeDeepLinkRouter { ) fallback(url) case nil: - break + DIGraphShared.shared.logger.info( + "Customer.io ignored a late or unknown React Native deep-link acknowledgement; " + + "the native fallback may have already routed the destination" + ) } } diff --git a/ios/wrappers/NativeCustomerIO.mm b/ios/wrappers/NativeCustomerIO.mm index 23ccd432..925376c0 100644 --- a/ios/wrappers/NativeCustomerIO.mm +++ b/ios/wrappers/NativeCustomerIO.mm @@ -78,6 +78,8 @@ - (void)invalidate { [_swiftBridge invalidate]; } +// Pins the Codegen event name at compile time. Keep this in sync with the TypeScript spec and the +// runtime selector in NativeCustomerIO.swift. - (void)emitOnDeepLinkReceived:(NSDictionary *)value { [super emitOnDeepLinkReceived:value]; } diff --git a/ios/wrappers/NativeCustomerIO.swift b/ios/wrappers/NativeCustomerIO.swift index 590a2633..cffee6c3 100644 --- a/ios/wrappers/NativeCustomerIO.swift +++ b/ios/wrappers/NativeCustomerIO.swift @@ -88,6 +88,8 @@ public class NativeCustomerIO: NSObject { private func sendDeepLink(id: String, url: String) { guard let emitter = objcEventEmitter else { return } + // Codegen derives this selector from `onDeepLinkReceived` in NativeCustomerIO.ts. Keep it + // in sync with that spec property and the forwarding override in NativeCustomerIO.mm. let selector = Selector(("emitOnDeepLinkReceived:")) guard emitter.responds(to: selector) else { logger.error("Customer.io could not emit a React Native deep-link event") diff --git a/src/customerio-cdp.ts b/src/customerio-cdp.ts index ce60d7c7..56051166 100644 --- a/src/customerio-cdp.ts +++ b/src/customerio-cdp.ts @@ -85,11 +85,15 @@ export class CustomerIO { /** * Register an acknowledged deep-link handler for an iOS UIScene host. * + * This API only handles Customer.io destinations delivered through an iOS UIScene lifecycle. * In a native React Native scene host, pair this with * `NativeCustomerIO.configureAcknowledgedSceneDeepLinkRouting()` in the SceneDelegate. Register * this before `initialize` when possible. Return `true` after routing a URL. Returning `false`, - * throwing, or rejecting lets the native SDK try the host AppDelegate and then the system. Cold - * URLs are buffered until this handler is registered, subject to the native timeout. + * throwing, or rejecting lets the native SDK try the host AppDelegate, then React Native Linking + * for a host-owned custom scheme or the system for other URLs. Cold URLs are buffered until this + * handler is registered, subject to the native timeout. After delivery, the handler has ten + * seconds to settle. Once that timeout runs the native fallback, a late handler cannot cancel it + * and may cause a second navigation if it also routes the URL. */ static readonly setDeepLinkHandler = ( handler: DeepLinkHandler @@ -98,6 +102,8 @@ export class CustomerIO { throw new Error('[CustomerIO] "handler" must be a function.'); } + // Native unregister is tokenless and removes the current native handler. Remove the old + // subscription first so its cleanup cannot unregister the replacement. deepLinkHandlerSubscription?.remove(); return withNativeModule((native) => {