config-plugins icon indicating copy to clipboard operation
config-plugins copied to clipboard

Branch.subscribe() not receiving deep link params on cold start with Expo SDK 54 (iOS)

Open virajpsimformsolutions opened this issue 2 months ago โ€ข 4 comments

โ—๏ธ 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:

  • onOpenStart should receive the URI

  • onOpenComplete should 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

  1. Kill the app\
  2. Tap a Branch Universal Link\
  3. 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

virajpsimformsolutions avatar Dec 03 '25 14:12 virajpsimformsolutions

Is the new arch enabled?

IgorBuenoConceptaTech avatar Dec 03 '25 18:12 IgorBuenoConceptaTech

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

IgorBuenoConceptaTech avatar Dec 03 '25 19:12 IgorBuenoConceptaTech

@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
    1. Adds RNBranch.initSession in didFinishLaunchingWithOptions
    1. 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.

virajpsimformsolutions avatar Dec 04 '25 12:12 virajpsimformsolutions

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

IgorBuenoConceptaTech avatar Dec 04 '25 13:12 IgorBuenoConceptaTech