Skip to content

fix(ios): forward open URLs to React Native linking before Expo subscribers - #530

Open
dustinsoftware wants to merge 1 commit into
callstack:mainfrom
dustinsoftware:fix/ios-open-url-linking-first
Open

dustinsoftware wants to merge 1 commit into
callstack:mainfrom
dustinsoftware:fix/ios-open-url-linking-first

Conversation

@dustinsoftware

@dustinsoftware dustinsoftware commented Nov 12, 2026 •

Copy link
Copy Markdown

This is an upstream port of a patch in our Expo 58 app, but also applies to 57. It fixes some edge cases around opening deep links. This only affects dev builds.

I did not see an easy way to add tests for this.

Summary

ExpoHostRuntime.application(_:open:options:) evaluated the Expo app delegate subscribers first and joined them with RCTLinkingManager through a short-circuiting ||. When a subscriber returned true, React Native never received the URL and the JS Linking url event did not fire, so Expo Router / React Navigation did not navigate.

In an Expo SDK 58 debug build with expo-dev-client, ExpoDevLauncherAppDelegateSubscriber is such a subscriber: EXDevLauncherController.onDeepLink(_:options:) stores any non-launcher URL as a pending deep link and returns true while its own React delegate handler has no running React host (expo-dev-launcher/ios/EXDevLauncherController.m:315-323, 368-376). That is the normal state in a brownfield host, so every custom-scheme link opened while the app runs was lost.

This change calls React Native linking first and ORs the subscriber result in. That is the order application(_:continue:restorationHandler:) already uses a few lines below:

let result = RCTLinkingManager.application(app, open: url, options: options)
return (expoDelegate?.application(app, open: url, options: options) ?? false) || result
  • The subscribers still run for every URL (expo-linking still records it), and the return value is still the OR of both.
  • ReactNativeHostRuntime (vanilla) has no subscribers and is unchanged.
  • A changeset (patch, @callstack/react-native-brownfield) is included.

Relation to #525

#525 changes the same line to call BrownfieldLinking.open(...) and keeps the current order. The two changes are independent, but they touch the same line, so whichever merges second needs a one-line rebase. After both, the method reads:

let result = BrownfieldLinking.open(app, url: url, options: options)
return (expoDelegate?.application(app, open: url, options: options) ?? false) || result

That combined form has been running as a local patch on top of 6.1.1 plus a #525 backport in an Expo SDK 58 / React Native 0.88 scene host.

Test plan

  • The changed file parses with swiftc -parse. The change is two lines of Swift, so JS lint and typecheck do not cover it.
  • Manual (debug, expo-dev-client installed): embed an Expo SDK 58 framework with Expo Router and a custom scheme. Forward URLs through ReactNativeBrownfield.shared.application(_:open:options:), show a React Native screen, and run xcrun simctl openurl booted "<scheme>://explore". Before the change the app stays on the current screen and RCTLinkingManager.application(_:open:options:) is not called. After it, Expo Router opens the Explore route. On a React Native 0.88 host that declares UISceneConfigurations, this also needs fix(ios): deliver warm deep links in UIScene hosts on React Native 0.88 #525.
  • E2E: the example apps do not include expo-dev-client, and no subscriber in them returns true for an app URL, so the existing Detox suites cannot reach this branch. It could be covered by adding a small local Expo module to ExpoApp58 whose ExpoAppDelegateSubscriber returns true for a test URL, and extending the warm deep link test from fix(ios): deliver warm deep links in UIScene hosts on React Native 0.88 #525 to open that URL and expect the route. That was left out to keep a test-only native module out of the example. It can be added if maintainers prefer.

Other options that were considered

  • Swapping the operands of || (RCTLinkingManager... || expoDelegate...): that just moves the short-circuit, so subscribers such as expo-linking would stop receiving URLs that React Native accepts. Evaluating both and then ORing them keeps every handler running, and it mirrors the existing continue userActivity overload.
  • Changing expo-dev-launcher to return false when it does not own the React host: the bug belongs to brownfield's ordering, and any other subscriber that returns true would hit it the same way.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Gf7Xx7K4DTaYw2pLT2TJF7

…ribers

ExpoHostRuntime.application(_:open:options:) joined the Expo app delegate subscribers and RCTLinkingManager with a short-circuiting ||, so a subscriber returning true kept the URL from React Native and the JS Linking 'url' event never fired. Evaluate the React Native result first and OR the subscribers in, as the continue userActivity overload already does.
@dustinsoftware
dustinsoftware marked this pull request as ready for review November 12, 2026 17:11
Copilot AI balanced review requested due to automatic review settings November 12, 2026 17:11

Copilot AI 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.

🟢 Approval recommended

No active review issues are reported for the iOS Expo URL-forwarding change.

No open issues 🎉

What changed in this PR

Fixes URL forwarding in the iOS Expo host so an Expo subscriber returning true no longer prevents React Native linking from receiving the URL. This complements #525’s separate fix for warm deep links in scene-based hosts.

Changes:

  • Calls React Native linking before Expo subscribers, ensuring both handlers run while preserving their combined return value.
  • Adds a patch changeset for @callstack/react-native-brownfield.
File Description
packages/​react-native-brownfield/​ios/​Expo/​ExpoHostRuntime.swift Evaluates React Native linking before invoking Expo URL subscribers.
.changeset/​ios-open-url-linking-first.md Documents the URL-forwarding fix for a patch release.

Review effort: Balanced

This branch has not been deployed

No deployments
Sign up for free to 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