Uh oh!
There was an error while loading. Please reload this page.
feat: support deep-link routing in React Native UIScene hosts - #641
Conversation
Sample app builds 📱Below you will find the list of the latest versions of the sample apps. It's recommended to always download the latest builds of the sample apps to accurately test the pull request.
|
| if CustomerIOReactNativeDeepLinkRouter.isSceneLifecycleEnabled { | ||
| _ = sdkConfigBuilder.deepLinkCallback { url in | ||
| CustomerIOReactNativeDeepLinkRouter.route(url) | ||
| return true |
There was a problem hiding this comment.
🔴 No fallback. Returns true unconditionally after a fire-and-forget post, which tells the SDK "handled" and ends its routing. If the JS listener doesn't route the URL, nothing happens — no external open. Regression: today an unhandled https reaches Safari via the SDK's system-open fallback. customerio-flutter#399 handles this with openExternally. Agreed RN's URL event has no handled result, but two wrappers shouldn't have different contracts for the same callback.
There was a problem hiding this comment.
Partly agreed. The missing-listener HTTP(S) behavior is real, and commit 7077c90 now states that consequence explicitly. I kept return true: React Native Linking has no handled result, so returning false or opening externally after publishing would also run native fallback when JavaScript successfully handles the URL, causing duplicate navigation or Safari. Flutter can defer fallback because its route reports handled or unhandled; React Native cannot. The registered JavaScript listener therefore owns navigation and external HTTP(S) fallback.
| return !configurations.isEmpty | ||
| } | ||
| static func route(_ url: URL) { |
There was a problem hiding this comment.
🔴 No readiness wait. A push-tap deep link can fire before RCTLinkingManager exists, and NotificationCenter doesn't buffer — a post with no observer is gone. customerio-flutter#399 handles the identical race with a 3s / 60×50ms wait for an engine. Combined with the missing fallback above, a cold-start push tap can lose the link entirely with nothing logged.
Can we verify if this actually happens?
There was a problem hiding this comment.
The notification is unbuffered, but I could not validate a separate cold-start race under the documented ordering. The callback is installed only when JavaScript calls CustomerIO.initialize, the listener must be registered first, and cold Live Activity delivery uses launch options/getInitialURL rather than this notification. Test-only #640 exercised an RN 0.88 scene host with listener-before-initialize and asserted the callback URL was delivered exactly once: https://github.com/customerio/customerio-reactnative/actions/runs/32653626934. A fixed three-second wait has no RN readiness signal to observe and would not make a missing listener safe, so I have not added it. Commit 7077c90 now states the failure mode if the ordering contract is not followed.
| let sdkConfigBuilder = try SDKConfigBuilder.create(from: config) | ||
| if CustomerIOReactNativeDeepLinkRouter.isSceneLifecycleEnabled { |
There was a problem hiding this comment.
🔴 Same gate issue as customerio-flutter#399. AppDelegate-only RN hosts keep using the SDK's app-delegate continue: handoff and break when we remove it. The sample's AppDelegate forwards application(_:continue:) to RCTLinkingManager, so it's in that group.
There was a problem hiding this comment.
This premise does not apply to the current dependency graph. The pinned Customer.io iOS 4.7.5 SDK still retains callback → AppDelegate continue → system-open fallback, and we are intentionally preserving it for AppDelegate-only hosts. Registering the React Native callback unconditionally would bypass existing AppDelegate filtering and change current behavior. If native iOS ever removes that fallback, it will require a coordinated wrapper migration; this PR does not remove it.
| final class CustomerIOReactNativeDeepLinkRouter { | ||
| private static let sceneManifestKey = "UIApplicationSceneManifest" | ||
| private static let sceneConfigurationsKey = "UISceneConfigurations" | ||
| private static let openURLNotification = Notification.Name("RCTOpenURLNotification") |
There was a problem hiding this comment.
🟠 RCTOpenURLNotification as a string literal and RCTIsSceneDelegateApp() reimplemented from plist keys — two private-RN mirrors that drift silently. Add a note for which RN versions these were verified against.
There was a problem hiding this comment.
Agreed. Commit 7077c90 adds provenance: RCTOpenURLNotification and its payload were verified against React Native 0.83.6 and the 0.88.0 nightly used by #640; the scene predicate and connection-options conversion are explicitly tied to that 0.88 scene support. I did not add another runtime shim or maintenance framework.
mahmoud-elmorabea
left a comment
There was a problem hiding this comment.
Looks good, one thing before we merge:
Can we device-test the edge cases? There's no fallback path, so every miss is a silently dropped link (the Flutter counterpart, customerio-flutter#399, falls back to UIApplication.open — worst case there is "opens in browser").
With a scene host and an https:// push destination:
- Warm app + listener → routes into JS (happy path)
- Warm app, no listener → confirm the drop is intended (previously opened Safari)
- Cold-start tap → does the listener attach reliably beat delivery? (getInitialURL() returns nothing)
- Cold-start with late listener (async bootstrap) → what does the user see?
- In-app "open browser" action → now lands in JS; is the README guidance enough to restore Safari?
- Natively-initialized host (Expo) → callback not installed, old behavior intact
Follow-up validation completed against the current PR stack:
Cases 2 and 4 are therefore confirmed constraints, but they do not block the intended iOS 27 / UIScene adoption path, which requires registering the Linking listener before CustomerIO.initialize. React Native Linking provides neither a synchronous handled result nor a listener-readiness signal. Native code cannot safely open a fallback URL after publishing it to JavaScript without also risking duplicate navigation when JavaScript handles it successfully. Supporting missing or late listeners reliably would require a new JavaScript-to-native acknowledgement and replay contract. That is a separate, larger behavior change rather than part of the UIScene compatibility fix. The reliable acknowledgement, replay, and fallback design is tracked separately in MBL-2301, including the Iterable and OneSignal reference patterns. |
Uh oh!
There was an error while loading. Please reload this page.
Summary
Compatibility
UIScene hosts call NativeCustomerIO.configureSceneDeepLinkRouting() at the start of scene(_:willConnectTo:options:), then register the JavaScript Linking listener before CustomerIO.initialize. Cold URLs are buffered until React Native initialization and delivered in order. If React Native does not initialize within ten seconds, the system fallback opens the destination instead of retaining it indefinitely. The integration remains single-window; no notification delegate, scene, window, or push-provider ownership changes.
Validation
Simulator injection validates presentation, tap handling, native buffering, and React Native Linking delivery. Physical-device APNs/FCM token registration and Customer.io backend sent/delivered/opened evidence remain separate.