Skip to content

feat: support deep-link routing in React Native UIScene hosts - #641

Merged
Shahroz16 merged 17 commits into
mainfrom
codex/mbl-2234-react-native-scene-deep-links
Aug 26, 2026
Merged

feat: support deep-link routing in React Native UIScene hosts#641
Shahroz16 merged 17 commits into
mainfrom
codex/mbl-2234-react-native-scene-deep-links

Conversation

@Shahroz16

@Shahroz16Shahroz16 commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • route SDK-triggered push, in-app, inbox, and Live Activity destinations through React Native Linking for UIScene hosts
  • install the bridge at scene connection time so terminated notification taps are not lost before JavaScript initialization
  • keep AppDelegate-only, older React Native, Expo native initialization, and custom native callbacks on their existing paths

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

  • TypeScript, ESLint, package-content, CocoaPods installation, APN sample, and public API checks passed
  • a generated React Native 0.88 UIScene host built against this exact wrapper on Xcode 26.6 / iOS 26.5
  • real simulator notifications passed exact-destination assertions from both terminated and warm states
  • test: add React Native scene notification E2E (MBL-2303) #643 adds the same credential-free test to the Xcode 27 nightly/manual lane

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.

@Shahroz16
Shahroz16 requested a review from a team as a code ownerAugust 23, 2026 15:09
@github-actions

github-actionsBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

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.


  • iOS APN: 641.17.0 (29794645)
  • Android APN: 641.17.0 (29794645)

Comment threadios/wrappers/NativeCustomerIO.swift Outdated
if CustomerIOReactNativeDeepLinkRouter.isSceneLifecycleEnabled {
_ = sdkConfigBuilder.deepLinkCallback { url in
CustomerIOReactNativeDeepLinkRouter.route(url)
return true

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.

🔴 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.

@Shahroz16Shahroz16Aug 24, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

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) {

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.

🔴 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?

@Shahroz16Shahroz16Aug 24, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

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 {

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.

🔴 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.

@Shahroz16Shahroz16Aug 24, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

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")

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.

🟠 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.

@Shahroz16Shahroz16Aug 24, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

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-elmorabeamahmoud-elmorabea left a comment

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.

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:

  1. Warm app + listener → routes into JS (happy path)
  2. Warm app, no listener → confirm the drop is intended (previously opened Safari)
  3. Cold-start tap → does the listener attach reliably beat delivery? (getInitialURL() returns nothing)
  4. Cold-start with late listener (async bootstrap) → what does the user see?
  5. In-app "open browser" action → now lands in JS; is the README guidance enough to restore Safari?
  6. Natively-initialized host (Expo) → callback not installed, old behavior intact

@Shahroz16

Shahroz16 commented Aug 26, 2026

Copy link
Copy Markdown
ContributorAuthor

Follow-up validation completed against the current PR stack:

  1. Warm app with a listener: the URL routes into JavaScript.
  2. Warm app without a listener: the app opens, but JavaScript receives no URL and Safari does not open.
  3. Cold start with the listener registered before CustomerIO initialization: the URL routes into JavaScript.
  4. Cold start with a listener registered after initialization: the URL is not replayed to the late listener.
  5. When the JavaScript listener receives the SDK URL and calls Linking.openURL, Safari opens the destination as documented.
  6. In a natively initialized scene host, the wrapper callback is not installed. The existing native fallback opens Safari and JavaScript receives no event.

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.

@Shahroz16Shahroz16 changed the title fix: route scene deep links through React Native (MBL-2234)feat: support deep-link routing in React Native UIScene hosts (MBL-2234)Aug 26, 2026
@Shahroz16Shahroz16 changed the title feat: support deep-link routing in React Native UIScene hosts (MBL-2234)feat: support deep-link routing in React Native UIScene hostsAug 26, 2026
@Shahroz16
Shahroz16 merged commit 0c6bb16 into mainAug 26, 2026
19 checks passed
@Shahroz16
Shahroz16 deleted the codex/mbl-2234-react-native-scene-deep-links branch August 26, 2026 04:56
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Shahroz16@mahmoud-elmorabea