Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,16 @@ On iOS, a `UIScene` host must install the wrapper's deep-link bridge before Reac
NativeCustomerIO.configureSceneDeepLinkRouting()
```

Then register the app's JavaScript `Linking` URL listener before calling `CustomerIO.initialize`. This ordering is required. Customer.io push, in-app, and inbox destinations are buffered during cold launch, then delivered through React Native's standard `Linking` API after initialization. If React Native does not initialize within ten seconds, the SDK opens the destination through the system instead of retaining it indefinitely. The listener owns the routing decision: navigate destinations your app handles, and use the app's normal external-browser path for other HTTP(S) destinations. React Native's native URL event has no handled result that the SDK can use for this decision, so a destination published after initialization without a listener is not opened externally, which would risk duplicate navigation. Natively initialized hosts, including Expo config-plugin integrations, keep their existing callback. Older React Native versions and AppDelegate-only hosts also keep their existing deep-link integration.
Then register the app's JavaScript `Linking` URL listener before calling `CustomerIO.initialize`. This ordering is required. The Expo plugin detects its scene lifecycle during initialization, so Expo app code does not call the native installation method itself. Customer.io push, in-app, and inbox destinations are buffered during cold launch, then delivered through React Native's standard `Linking` API after initialization. If React Native does not initialize within ten seconds, the SDK opens the destination through the system instead of retaining it indefinitely. The listener owns the routing decision: navigate destinations your app handles, and use the app's normal external-browser path for other HTTP(S) destinations. React Native's native URL event has no handled result that the SDK can use for this decision, so a destination published after initialization without a listener is not opened externally, which would risk duplicate navigation. Older React Native versions and AppDelegate-only hosts keep their existing deep-link integration.

An Expo app using config-plugin auto-initialization does not call `CustomerIO.initialize`. Register its `Linking` listener, then mark that listener ready instead:

```typescript
const subscription = Linking.addEventListener('url', ({ url }) => {
// Route the URL in your app.
});
CustomerIO.setDeepLinkRoutingReady();
```

This integration applies after the host has adopted React Native's UIScene lifecycle; the plugin does not replace React Native's root application lifecycle.

Expand Down
50 changes: 50 additions & 0 deletions __tests__/deep-link-routing-readiness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
jest.mock('react-native', () => ({
Platform: {
OS: 'ios',
select: (spec: { [key: string]: unknown }) =>
spec.ios ?? spec.default ?? undefined,
},
}));

jest.mock('../src/customerio-geofence', () => ({
CustomerIOGeofence: class {},
}));
jest.mock('../src/customerio-inapp', () => ({
CustomerIOInAppMessaging: class {},
}));
jest.mock('../src/customerio-liveactivities', () => ({
CustomerIOLiveActivities: class {},
}));
jest.mock('../src/customerio-location', () => ({
CustomerIOLocation: class {},
}));
jest.mock('../src/customerio-push', () => ({
CustomerIOPushMessaging: class {},
}));
jest.mock('../src/native-logger-listener', () => ({
NativeLoggerListener: {
initNativeLogger: jest.fn(),
initialize: jest.fn(),
},
}));
jest.mock('../src/specs/modules/NativeCustomerIO', () => ({
__esModule: true,
default: {
setDeepLinkRoutingReady: jest.fn(),
},
}));

import { CustomerIO } from '../src/customerio-cdp';
import NativeCustomerIO from '../src/specs/modules/NativeCustomerIO';

describe('CustomerIO scene deep-link readiness', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('notifies the native router after the host registers its Linking listener', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this asserts the TypeScript wrapper forwards to the native module. The logic the PR actually adds is untested:

  • isExpoSceneLifecycleEnabled (router:45-47)
  • installForExpoSceneLifecycle (router:57-60)
  • the shouldInstallSceneRouter composition (NativeCustomerIO.swift:92-95)

There's no Swift test target in this repo, and #643's E2E builds a plain React Native host rather than an Expo one, so the Expo branch has no coverage at any level. customerio/customerio-flutter has test/ios27_lifecycle/swift/CustomerIOURLRoutingBehaviorTests.swift for the equivalent logic — worth standing up the same here.

CustomerIO.setDeepLinkRoutingReady();

expect(NativeCustomerIO.setDeepLinkRoutingReady).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ class NativeCustomerIOModule(
}
}

override fun setDeepLinkRoutingReady() {
// UIScene and React Native Linking readiness are iOS-only concerns.
}

override fun identify(params: ReadableMap?) {
val userId = params?.getString("userId")
val traits = params?.getMap("traits")
Expand Down
1 change: 1 addition & 0 deletions api-extractor-output/customerio-reactnative.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export class CustomerIO {
static readonly pushMessaging: CustomerIOPushMessaging;
static readonly registerDeviceToken: (token: string) => Promise<void>;
static readonly screen: (title: string, properties?: Record<string, any>) => Promise<any>;
static readonly setDeepLinkRoutingReady: () => void;
static readonly setDeviceAttributes: (attributes: Record<string, any>) => Promise<any>;
static readonly setProfileAttributes: (attributes: Record<string, any>) => Promise<any>;
static readonly track: (name: string, properties?: Record<string, any>) => Promise<any>;
Expand Down
3 changes: 3 additions & 0 deletions customerio-reactnative.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ Pod::Spec.new do |s|
s.platforms = { :ios => min_ios_version_supported }
s.source = { :git => "https://github.com/customerio/customerio-ios.git", :tag => "#{s.version}" }

# The generated Swift compatibility header imports
# <customerio_reactnative/customerio_reactnative.h> in static-library builds.
s.header_dir = "customerio_reactnative"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3header_dir changes where CocoaPods installs this pod's public headers. Necessary for the static-library build, but it's a packaging change inside a feature PR — worth a release-note line in case anything imports the old path.

s.source_files = "ios/wrappers/**/*.{h,m,mm,swift}"

# Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0.
Expand Down
34 changes: 29 additions & 5 deletions ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,45 @@ enum CustomerIOReactNativeDeepLinkRouter {
private static var isReactNativeReady = false
private static var pendingUrls: [PendingUrl] = []

private static var hasSceneManifest: Bool {
guard let manifest = Bundle.main.object(forInfoDictionaryKey: sceneManifestKey) as? [String: Any],
let configurations = manifest[sceneConfigurationsKey] as? [String: Any]
else { return false }

return !configurations.isEmpty
}

/// Mirrors `RCTIsSceneDelegateApp()` without linking to the internal symbol, and also requires
/// React Native's scene-Linking entry point. A scene manifest alone does not make older React
/// Native versions scene-aware.
static var isSceneLifecycleEnabled: Bool {
guard let manifest = Bundle.main.object(forInfoDictionaryKey: sceneManifestKey) as? [String: Any],
let configurations = manifest[sceneConfigurationsKey] as? [String: Any]
else { return false }
guard !configurations.isEmpty,
guard hasSceneManifest,
let linkingManager = NSClassFromString("RCTLinkingManager")
else { return false }

return linkingManager.responds(to: sceneOpenURLContextsSelector)
}

/// Expo owns scene-to-Linking forwarding even when its React Native version does not expose
/// the scene selector itself.
static var isExpoSceneLifecycleEnabled: Bool {
hasSceneManifest && NSClassFromString("EXExpoAppSceneDelegate") != nil
}

static func install() {
guard isSceneLifecycleEnabled else { return }
installCallback()
}

/// Expo owns scene-to-Linking forwarding even on React Native versions that do not expose the
/// scene selector themselves. Require both a non-empty scene manifest and Expo's scene delegate
/// class so older Expo apps with unrelated scene configurations keep their existing behavior.
static func installForExpoSceneLifecycle() {
guard isExpoSceneLifecycleEnabled else { return }
installCallback()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this install path succeeds silently, and the requirement it creates is only observable 10 seconds after the first deep link.

For a config-plugin auto-init host, readiness can arrive only via setDeepLinkRoutingReady(); markReactNativeReady() is otherwise reached only from initialize (NativeCustomerIO.swift:73 and :165), which such hosts never call. Miss that one JS call and every destination stalls for 10s, then gets opened externally instead of routed in-app.

The expire log at :135-139 is good but arrives too late to prevent it. Logging at install time that readiness is required would surface a misconfiguration on first launch rather than on first link.


private static func installCallback() {
DIGraphShared.shared.deepLinkUtil.setDeepLinkCallback { url in
accept(url)
return true
Expand Down Expand Up @@ -111,7 +133,9 @@ enum CustomerIOReactNativeDeepLinkRouter {
stateLock.unlock()

DIGraphShared.shared.logger.error(
"Customer.io is opening an SDK deep link externally because React Native did not initialize in time"
"Customer.io is opening an SDK deep link externally because React Native did not " +
"initialize in time. Native-auto-initialized apps must call " +
"CustomerIO.setDeepLinkRoutingReady() after registering their Linking listener"
)
UIApplication.shared.open(url) { opened in
guard !opened else { return }
Expand Down
5 changes: 5 additions & 0 deletions ios/wrappers/NativeCustomerIO.mm
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ - (void)initialize:(NSDictionary *)config
[_swiftBridge initialize:config args:args resolve:resolve reject:reject];
}

- (void)setDeepLinkRoutingReady {
[self assertBridgeAvailable:@"during setDeepLinkRoutingReady"];
[_swiftBridge setDeepLinkRoutingReady];
}

- (void)identify:(NSDictionary *)params {
[self assertBridgeAvailable:@"during identify"];
[_swiftBridge identify:params];
Expand Down
35 changes: 34 additions & 1 deletion ios/wrappers/NativeCustomerIO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import CioAnalytics
import CioDataPipelines
import CioInternalCommon
import CioMessagingInApp
import Foundation

@objc(NativeCustomerIO)
public class NativeCustomerIO: NSObject {
Expand All @@ -20,6 +21,35 @@ public class NativeCustomerIO: NSObject {
CustomerIOReactNativeDeepLinkRouter.install()
}

/// Installs deep-link routing for an Expo-owned scene lifecycle.
///
/// Expo forwards scene URLs into React Native Linking itself, including on React Native
/// versions that do not expose the scene Linking selector used by the generic integration.
@objc
public static func configureExpoSceneDeepLinkRouting() {
guard CustomerIO.shared.implementation == nil else { return }
// Native initialization preserves an existing callback when its config does not provide one.
CustomerIOReactNativeDeepLinkRouter.installForExpoSceneLifecycle()
}

/// Reports and unwraps a Live Activity widget URL when that module is installed.
/// Keeping this entry point in the base wrapper lets generated scene code remain valid when an
/// incremental prebuild later disables the Live Activities subspec.
@objc
public static func handleLiveActivityWidgetUrl(_ url: URL) -> URL? {
#if CIO_LIVEACTIVITIES_ENABLED
NativeLiveActivities.handleWidgetUrl(url)
#else
url
#endif
}

/// Marks the React Native Linking listener as ready for buffered scene URLs.
@objc
func setDeepLinkRoutingReady() {
CustomerIOReactNativeDeepLinkRouter.markReactNativeReady()
}

/// Ensures that the CustomerIO SDK is initialized before performing operations.
/// Logs an error and returns false if the SDK is not initialized.
private func ensureInitialized() -> Bool {
Expand Down Expand Up @@ -59,7 +89,10 @@ public class NativeCustomerIO: NSObject {

let sdkConfigBuilder = try SDKConfigBuilder.create(from: config)

if CustomerIOReactNativeDeepLinkRouter.isSceneLifecycleEnabled {
let shouldInstallSceneRouter =
CustomerIOReactNativeDeepLinkRouter.isSceneLifecycleEnabled ||
(packageSource == "Expo" && CustomerIOReactNativeDeepLinkRouter.isExpoSceneLifecycleEnabled)
if shouldInstallSceneRouter {
_ = sdkConfigBuilder.deepLinkCallback { url in
CustomerIOReactNativeDeepLinkRouter.accept(url)
return true // React Native Linking cannot report handling; JavaScript owns fallback.
Expand Down
6 changes: 6 additions & 0 deletions ios/wrappers/customerio_reactnative.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#pragma once

#import <Foundation/Foundation.h>

// Public module header required when CocoaPods builds the wrapper as a static library. Swift's
// generated compatibility header imports this path before exposing the wrapper's Swift types.
10 changes: 10 additions & 0 deletions src/customerio-cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ export class CustomerIO {
});
};

/**
* Signal that the app's React Native Linking listener is ready to receive Customer.io URLs.
*
* This is only needed by UIScene hosts that initialize Customer.io natively, such as Expo
* config-plugin auto-initialization. Call it after registering the Linking listener.
*/
static readonly setDeepLinkRoutingReady = () => {
return withNativeModule<void>((native) => native.setDeepLinkRoutingReady());
};

/** Identify a user to start tracking their activity. Requires userId, traits, or both. */
static readonly identify = async ({
userId,
Expand Down
1 change: 1 addition & 0 deletions src/specs/modules/NativeCustomerIO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface Spec extends TurboModule {
config: NativeBridgeObject,
args: NativeBridgeObject
): Promise<void>;
setDeepLinkRoutingReady(): void;
identify(params?: NativeBridgeObject): void;
clearIdentify(): void;
track(name: string, properties?: NativeBridgeObject): void;
Expand Down
Loading