Branch.subscribe() not receiving deep link params on cold start with Expo SDK 54 (iOS)
โ๏ธ Branch.io Deep Link Not Working on iOS Cold Start (Expo SDK 54 + react-native-branch 6.8.0)
๐ Summary
When opening the app from a Branch.io deep link on a cold start (via
QR code, Safari, or Messages),
branch.subscribe() fires onOpenComplete but does not return proper
deep link parameters.
+clicked_branch_link is always false and all metadata is missing.
This issue occurs only on iOS and only when the app is cold started.
๐ฑ Environment
Library / Platform Version
react-native-branch 6.8.0
Expo SDK 54
React Native 0.79
Platform iOS
Build Type EAS Custom Dev Client (not Expo Go)
Branch Config Plugin @config-plugins/react-native-branch 11.0.0
โ Expected Behavior
When opening the app from a Branch link on cold start:
-
onOpenStartshould receive the URI -
onOpenCompleteshould return parameters including:{ "+clicked_branch_link": true, "~campaign": "...", "testParam": "testValue" }
โ Actual Behavior
onOpenComplete fires but parameters are empty:
{
"error": null,
"params": {
"+is_first_session": false,
"+clicked_branch_link": false
},
"uri": null
}
getFirstReferringParams() also returns:
{ "+clicked_branch_link": false }
๐ Reproduction Steps
1. Create new Expo app
npx create-expo-app@latest branch-test --template blank-typescript
cd branch-test
2. Install Branch dependencies
npx expo install react-native-branch @config-plugins/react-native-branch
3. Configure app.config.js
export default {
name: "branch-test",
slug: "branch-test",
version: "1.0.0",
ios: {
bundleIdentifier: "com.yourcompany.branchtest",
associatedDomains: [
"applinks:yourapp.test-app.link",
"applinks:yourapp-alternate.test-app.link"
],
config: {
branch: {
apiKey: "key_test_xxxxxxxxxxxxxx"
}
}
},
plugins: [
[
"@config-plugins/react-native-branch",
{
apiKey: "key_test_xxxxxxxxxxxxxx",
iosAssociatedDomains: ["applinks:yourapp.test-app.link"]
}
]
]
};
4. Use this test App.tsx
import { useEffect } from 'react';
import { StyleSheet, Text, View, Button } from 'react-native';
import branch from 'react-native-branch';
export default function App() {
useEffect(() => {
const unsubscribe = branch.subscribe({
onOpenStart: ({ uri, cachedInitialEvent }) => {
console.log('๐ข onOpenStart - URI:', uri);
console.log('๐ข cachedInitialEvent:', cachedInitialEvent);
},
onOpenComplete: ({ error, params, uri }) => {
console.log('๐ต onOpenComplete:', JSON.stringify({ error, params, uri }, null, 2));
console.log('๐ต +clicked_branch_link:', params?.['+clicked_branch_link']);
}
});
setTimeout(async () => {
const firstParams = await branch.getFirstReferringParams();
console.log('๐ getFirstReferringParams:', JSON.stringify(firstParams, null, 2));
}, 3000);
return () => unsubscribe();
}, []);
return (
<View style={styles.container}>
<Text>Branch Test</Text>
<Button title="Test Link (Works)" onPress={() => branch.openURL('https://yourapp.test-app.link/test')} />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center' }
});
5. Build iOS dev client
npx expo prebuild --clean
eas build --platform ios --profile development
6. Test
- Kill the app\
- Tap a Branch Universal Link\
- Observe console
๐ Test Branch Link Parameters
Created in dashboard:
- URL:
https://yourapp.test-app.link/test - Campaign:
test-campaign - Data:
{ "testParam": "testValue" }
๐ Observed Behavior Logs
Expected (but not happening):
๐ข onOpenStart URI: https://yourapp.test-app.link/test
๐ต +clicked_branch_link: true
Actual:
๐ต +clicked_branch_link: false
๐ต uri: null
๐ต params: { "+is_first_session": false }
๐งช Additional Observations
Scenario Works? Notes
Safari Universal Link (cold โ Params missing start)
QR Code (cold start) โ Same issue
App already running (warm โ ๏ธ Sometimes But still start) unreliable
Using branch.openURL() โ
Always returns
internally correct params
Deferred linking โ Always empty
(getFirstReferringParams)
Associated Domains configured โ Verified
appleTeamId set โ Verified
๐ Problem Summary
Branch fails to deliver any deep link parameters on cold start on iOS, even though:
โ๏ธ Associated domains are valid
โ๏ธ Branch SDK initializes
โ๏ธ App opens correctly
โ +clicked_branch_link stays false
โ No campaign or custom data is delivered
This breaks universal links, QR code flows, onboarding, and deferred deep linking.
๐ Request
Please investigate why Branch params are not delivered to React Native apps on iOS cold start when using:
- Expo SDK 54\
- React Native 0.79\
- react-native-branch 6.8.0\
- Config Plugins\
- EAS Dev Client
Is the new arch enabled?
@virajpsimformsolutions
Environment: RN: 0.81.5 Expo: 54 @config-plugins/react-native-branch: ^11.0.0 react-native-branch: ~6.9.0 New arch: enabled Android: Works normally iOS: Links navigate to the app, but not to the desired route, and the links are not recognized as branch links even though they are generated using the branch SDK. Same problem as @virajpsimformsolutions identified
I was preparing some code to share and create an issue as well. I started troubleshooting using AI, and it suggested a plugin to fix it.
It's not a definitive solution because it's not good practice to manually edit the app delegate in this way. But it worked for me, I used this custom plugin. To use the plugin, you need to be able to edit the app config via JS/TS: https://docs.expo.dev/config-plugins/plugins/
The plugin basically adds the branch import and adds the RNBranch.continue to the Universal Links handler.
import { ConfigPlugin, withAppDelegate } from "expo/config-plugins";
/**
* Config plugin to properly integrate Branch SDK in AppDelegate.swift
*
* This plugin:
* 1. Adds the RNBranch import
* 2. Adds RNBranch.initSession in didFinishLaunchingWithOptions
* 3. Adds RNBranch.continue in continueUserActivity
*
* The @config-plugins/react-native-branch plugin uses ExpoAppDelegateSubscriber,
* but that approach has race condition issues with Expo 54 / new architecture.
* Direct integration in AppDelegate ensures Branch receives links immediately.
*/
const withBranchAppDelegate: ConfigPlugin = config => {
return withAppDelegate(config, async mod => {
const { modResults } = mod;
let contents = modResults.contents;
// Step 1: Add RNBranch import if not present
if (!contents.includes("import RNBranch")) {
// Add import at the first line of the file
contents = `import RNBranch\n${contents}`;
}
// Step 2: Add RNBranch.continue in continueUserActivity if not present
if (!contents.includes("RNBranch.continue")) {
// Pattern for the default Expo-generated continueUserActivity method
const continueUserActivityPattern =
/let result = RCTLinkingManager\.application\(application, continue: userActivity, restorationHandler: restorationHandler\)\s*\n\s*return super\.application\(application, continue: userActivity, restorationHandler: restorationHandler\) \|\| result/;
if (continueUserActivityPattern.test(contents)) {
contents = contents.replace(
continueUserActivityPattern,
`// Handle Branch universal links first
let branchHandled = RNBranch.continue(userActivity)
let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler)
return branchHandled || super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result`
);
}
}
modResults.contents = contents;
return mod;
});
};
export default withBranchAppDelegate;
After running the prebuild command, you'll see some differences in the AppDelegate file, especially in the Universal Links handler:
import Expo
import React
import ReactAppDependencyProvider
import RNBranch
@UIApplicationMain
public class AppDelegate: ExpoAppDelegate {
var window: UIWindow?
var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
var reactNativeFactory: RCTReactNativeFactory?
public override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
let delegate = ReactNativeDelegate()
let factory = ExpoReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
reactNativeDelegate = delegate
reactNativeFactory = factory
bindReactNativeFactory(factory)
#if os(iOS) || os(tvOS)
window = UIWindow(frame: UIScreen.main.bounds)
factory.startReactNative(
withModuleName: "main",
in: window,
launchOptions: launchOptions)
#endif
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// Linking API
public override func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options)
}
// Universal Links
public override func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
// Handle Branch universal links
let branchHandled = RNBranch.continue(userActivity)
let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler)
return branchHandled || super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result
}
}
class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
// Extension point for config-plugins
override func sourceURL(for bridge: RCTBridge) -> URL? {
// needed to return the correct URL for expo-dev-client.
bridge.bundleURL ?? bundleURL()
}
override func bundleURL() -> URL? {
#if DEBUG
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
#else
return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
}
Again, this is not a definite solution, but it can help to identify what is happening; it can be a race condition between the BranchAppDelegate, a missing configuration from our side, or a conflict with the AppDelegate event that needs attention.
Environment: RN: 0.81.5 Expo: 54 @config-plugins/react-native-branch: ^11.0.0 react-native-branch: ~6.9.0 New arch: enabled Android: Works normally iOS: Links navigate to the app, but not to the desired route, and the links are not recognized as branch links even though they are generated using the branch SDK. Same problem as @virajpsimformsolutions identified
I was preparing some code to share and create an issue as well. I started troubleshooting using AI, and it suggested a plugin to fix it.
It's not a definitive solution because it's not good practice to manually edit the app delegate in this way. But it worked for me, I used this custom plugin. To use the plugin, you need to be able to edit the app config via JS/TS: https://docs.expo.dev/config-plugins/plugins/
The plugin basically adds the branch import and adds the RNBranch.continue to the Universal Links handler.
import { ConfigPlugin, withAppDelegate } from "expo/config-plugins";
/**
Config plugin to properly integrate Branch SDK in AppDelegate.swift
This plugin:
- Adds the RNBranch import
- Adds RNBranch.initSession in didFinishLaunchingWithOptions
- Adds RNBranch.continue in continueUserActivity
The @config-plugins/react-native-branch plugin uses ExpoAppDelegateSubscriber,
but that approach has race condition issues with Expo 54 / new architecture.
Direct integration in AppDelegate ensures Branch receives links immediately. */ const withBranchAppDelegate: ConfigPlugin = config => { return withAppDelegate(config, async mod => { const { modResults } = mod; let contents = modResults.contents;
// Step 1: Add RNBranch import if not present if (!contents.includes("import RNBranch")) { // Add import at the first line of the file contents =
import RNBranch\n${contents}; }// Step 2: Add RNBranch.continue in continueUserActivity if not present if (!contents.includes("RNBranch.continue")) { // Pattern for the default Expo-generated continueUserActivity method const continueUserActivityPattern = /let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler)\s*\n\s*return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result/;
if (continueUserActivityPattern.test(contents)) { contents = contents.replace( continueUserActivityPattern,
// Handle Branch universal links first let branchHandled = RNBranch.continue(userActivity) let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) return branchHandled || super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result); } }modResults.contents = contents; return mod; }); };
export default withBranchAppDelegate; After running the prebuild command, you'll see some differences in the AppDelegate file, especially in the Universal Links handler:
import Expo import React import ReactAppDependencyProvider import RNBranch
@UIApplicationMain public class AppDelegate: ExpoAppDelegate { var window: UIWindow?
var reactNativeDelegate: ExpoReactNativeFactoryDelegate? var reactNativeFactory: RCTReactNativeFactory?
public override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { let delegate = ReactNativeDelegate() let factory = ExpoReactNativeFactory(delegate: delegate) delegate.dependencyProvider = RCTAppDependencyProvider()
reactNativeDelegate = delegate reactNativeFactory = factory bindReactNativeFactory(factory)#if os(iOS) || os(tvOS) window = UIWindow(frame: UIScreen.main.bounds) factory.startReactNative( withModuleName: "main", in: window, launchOptions: launchOptions) #endif
return super.application(application, didFinishLaunchingWithOptions: launchOptions)}
// Linking API public override func application( _ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) }
// Universal Links public override func application( _ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void ) -> Bool { // Handle Branch universal links let branchHandled = RNBranch.continue(userActivity) let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) return branchHandled || super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result } }
class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { // Extension point for config-plugins
override func sourceURL(for bridge: RCTBridge) -> URL? { // needed to return the correct URL for expo-dev-client. bridge.bundleURL ?? bundleURL() }
override func bundleURL() -> URL? { #if DEBUG return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") #else return Bundle.main.url(forResource: "main", withExtension: "jsbundle") #endif } } Again, this is not a definite solution, but it can help to identify what is happening; it can be a race condition between the BranchAppDelegate, a missing configuration from our side, or a conflict with the AppDelegate event that needs attention.
Thanks โ I tried your suggestion and it works! ๐ Iโve applied this as a temporary patch locally, and things behave as expected now.
For production-grade usage, we are still waiting for a proper, stable plugin from the Branch / config-plugins side, so this patch is just a stopgap until the official solution lands.
I'll try to tackle it next weekend to figure out if I can solve and create a PR, I'm not a Swift specialist but now we have a notion of what occurred