Skip to content

fix(ios): deliver warm deep links in UIScene hosts on React Native 0.88 - #525

Open
dustinsoftware wants to merge 1 commit into
callstack:mainfrom
dustinsoftware:fix/ios-scene-warm-links
Open

dustinsoftware wants to merge 1 commit into
callstack:mainfrom
dustinsoftware:fix/ios-scene-warm-links

Conversation

@dustinsoftware

@dustinsoftware dustinsoftware commented Nov 11, 2026 •

Copy link
Copy Markdown

This PR addresses an issue we hit while updating brownfield, expo, and react-native in our production app. As far as I can tell, it shouldn't affect any projects using React native 0.87 and lower. We are running this patch internally to work around the issue.

Summary

In an iOS host that declares UISceneConfigurations in its Info.plist and forwards URLs to React Native through brownfield, deep links and universal links that arrive while the app is running never reach JS on React Native 0.88. The Linking url event does not fire, so Expo Router / React Navigation do not navigate. This PR delivers those links again, without changing behaviour for hosts without scenes or for React Native 0.86 / 0.87.

Found with React Native 0.88.1, Expo SDK 58, @callstack/react-native-brownfield 6.1.1, Xcode 27 and an iOS 26.5 simulator. It reproduces on main with ExpoApp58 (React Native 0.88.0-rc.1).

Root cause

React Native 0.88 added a scene check to the app delegate entry points of RCTLinkingManager (Libraries/LinkingIOS/RCTLinkingManager.mm):

+ (BOOL)handleAppDelegateURL:(NSURL *)URL app:(UIApplication *)app
{
  if (RCTIsSceneDelegateApp()) {
    return NO;
  }

  [RCTLinkingManager postNotificationWithURL:URL];
  return YES;
}

+application:continueUserActivity:restorationHandler: has the same check. RCTIsSceneDelegateApp() is true for any app whose Info.plist has a non-empty UISceneConfigurations. Brownfield forwards ReactNativeBrownfield.shared.application(_:open:options:) and application(_:continue:restorationHandler:) to those entry points (ExpoHostRuntime / ReactNativeHostRuntime), so in a scene host they return NO and RCTOpenURLNotification is never posted. React Native 0.86 and 0.87 have no such check.

React Native 0.88's replacement, +scene:openURLContexts:, needs a UIOpenURLContext, which cannot be constructed in code, and +postNotificationWithURL: is private. SwiftUI's .onOpenURL and many host setups only have a URL at hand.

Fix

A small internal helper, BrownfieldLinking (ios/BrownfieldLinking.swift), used by both host runtimes:

  • It calls RCTLinkingManager exactly as before.
  • Only when RCTLinkingManager declines (returns false), it posts RCTOpenURLNotification with userInfo: ["url": url.absoluteString], the same payload React Native's own +postNotificationWithURL: posts. For user activities this is limited to NSUserActivityTypeBrowsingWeb with a webpageURL, mirroring React Native's +handleUserActivity:.

The fallback is keyed on the return value rather than on RCTIsSceneDelegateApp() / RCTLinkingManager.scene(_:continue:), because those symbols do not exist in React Native 0.86 / 0.87, which this repo still builds against (ExpoApp57, RNApp). On 0.86 / 0.87, and on 0.88 without scenes, RCTLinkingManager returns true and the helper does nothing extra, so there is no double delivery from React Native itself. In ExpoHostRuntime the existing evaluation order and result combining are unchanged.

Trade-offs

  • RCTOpenURLNotification is a React Native internal name. It has been stable for years, but it should be re-checked on React Native upgrades.
  • A React Native 0.88 scene host that also calls RCTLinkingManager.scene(_:openURLContexts:) itself, in addition to forwarding to brownfield, would now get the event twice. Before this change the brownfield forward did nothing in that setup. Expo SDK 58's ExpoAppSceneDelegate already de-duplicates a matching RCTOpenURLNotification.
  • application(_:open:options:) now returns true when React Native 0.88 declines in a scene app, which matches the pre-0.88 behaviour.

Alternative considered

New scene-shaped public entry points on ReactNativeBrownfield that forward to RCTLinkingManager.scene(_:openURLContexts:) / scene(_:continue:). These would only exist on React Native 0.88+, and they still cannot serve hosts that only have a URL (SwiftUI .onOpenURL, custom routers). They could be added later alongside this fallback for UIKit SceneDelegate hosts.

Example app changes (needed for the repro)

AppleApp uses SwiftUI's generated scene manifest, whose UISceneConfigurations is empty, so RCTIsSceneDelegateApp() was false and the bug was masked. To make the example a real scene host:

  • Brownfield-Apple-App-Info.plist: a brownfield-apple-app URL scheme and an explicit UIApplicationSceneManifest with one UIWindowSceneSessionRoleApplication configuration (single scene).
  • project.pbxproj: removed INFOPLIST_KEY_UIApplicationSceneManifest_Generation from all targets, because the generated manifest overwrites the one in the Info.plist.
  • BrownfieldAppleApp.swift: .onOpenURL and .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) forward to ReactNativeBrownfield.shared.

E2E test

appleAppExpoBrownfield.e2e.js gains "routes a deep link that arrives while the app is running". It opens the Expo home, opens brownfield-apple-app://explore with xcrun simctl openurl, accepts the system "Open in ...?" prompt via Detox's system API, and expects the Expo Router Explore screen. simctl openurl is used instead of device.openURL, because Detox delivers the latter by calling the app delegate, which bypasses the scene path this PR is about.

Only the Expo 58 variant exercises the fix: on Expo 57 (React Native 0.86) the test passes with or without it. The vanilla variant (RNApp, React Native 0.87) has no router or Linking listener, so it has no deep link test. Universal links are not covered by E2E (they need associated domains).

Test plan

Local runs on Xcode 27.2 with the Detox iPhone 17 simulator (iOS 27.2):

Check Result
New E2E on Expo 58, before the fix Fails: prompt accepted, app stays on Home
ci:local:appleapp:e2e:ios --variant expo58, after the fix 4/4 pass
Same, with UISceneConfigurations removed (host without scenes) 4/4 pass
ci:local:appleapp:e2e:ios (vanilla, React Native 0.87) 5/5 pass
ci:local:appleapp:e2e:ios --variant expo57 Not run locally (see notes). React Native 0.86 never reaches the fallback; CI covers this job
Cold start from a URL (scene host, after the fix) App launches normally. The example does not route cold links with or without this change: AppleApp passes launchOptions: nil, and in scene apps the launch URL is not in launchOptions.
yarn build, yarn lint, yarn typecheck, yarn test:apps Pass (yarn lint with the generated packages/brownfield-navigation files from brownfield:package:ios reverted, as CONTRIBUTING.md describes)
yarn test:packages One failure on main as well, unrelated: packages/cli/.../strip-framework-binary.test.ts nests it() inside it() in a block that only runs on macOS

Notes for local runs:

  • The Detox build uses CODE_SIGNING_ALLOWED=NO, and the Expo precompiled frameworks then end up unsigned in the app. On the iOS 26.5 and 27.2 simulator runtimes, dyld refuses to load them (Library missing ... ExpoModulesWorklets.framework). The local runs above ad-hoc signed the built app (codesign --force --sign -) before testing. CI on macos-26 may not hit this.
  • Expo 57 could not be built locally: Xcode 27.2 rejects its pods with deployment targets below iOS 15 (lottie-react-native, ReachabilitySwift), and the Xcode 26.6 install on the test machine has no iOS device platform for the packaging step.

Recordings

Detox recordings of the new test were captured for each case below (simulator only, example app only) and can be attached on request:

  • Before the fix (Expo 58): the system prompt is accepted and the app stays on the Home tab.
  • After the fix (Expo 58): the app switches to the Explore tab.
  • After the fix, host without scenes: the app switches to the Explore tab.

Possible follow-ups

  • A "Deep links" section in the iOS getting-started docs showing .onOpenURL / SceneDelegate forwarding to ReactNativeBrownfield.shared.
  • Moving the decision logic into the SwiftPM test package so the fallback matrix gets unit tests.
  • A deep link test for the vanilla variant once RNApp is on React Native 0.88.

🤖 Generated with Claude Code

React Native 0.88 returns NO from RCTLinkingManager's app delegate
openURL and continueUserActivity entry points when the app declares
UISceneConfigurations, so URLs forwarded through brownfield never
reached JS while the app was running. When RCTLinkingManager declines,
post RCTOpenURLNotification with the same payload React Native uses.

The example AppleApp now declares a scene configuration and a URL
scheme and forwards URLs from SwiftUI, and the Expo E2E suite opens a
deep link while the app is running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dustinsoftware
dustinsoftware marked this pull request as ready for review November 11, 2026 21:11
Copilot AI balanced review requested due to automatic review settings November 11, 2026 21: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 recorded for the iOS scene-based deep-link and universal-link delivery changes.

No open issues 🎉

What changed in this PR

Restores delivery of deep links and universal links received while iOS scene-based hosts are running on React Native 0.88, using a shared helper for both Expo and vanilla runtimes.

Changes:

  • Adds a notification fallback when React Native’s app-delegate linking methods decline a supported link.
  • Configures AppleApp as a scene-based host and forwards SwiftUI link events to brownfield.
  • Adds a system-delivered warm deep-link E2E test and a patch changeset.
File Description
packages/​react-native-brownfield/​ios/​Vanilla/​ReactNativeHostRuntime.swift Routes link handling through the shared helper.
packages/​react-native-brownfield/​ios/​Expo/​ExpoHostRuntime.swift Uses the helper while preserving Expo delegate evaluation order.
packages/​react-native-brownfield/​ios/​BrownfieldLinking.swift Implements fallback notifications for URLs and web user activities.
apps/​brownfield-example-shared-tests/​e2e/​detoxTiming.cjs Adds a timeout for the system URL-opening prompt.
apps/​brownfield-example-shared-tests/​e2e/​appleAppExpoBrownfield.e2e.js Tests navigation from a warm deep link.
apps/​brownfield-example-shared-tests/​e2e/​appleAppDetoxUtils.cjs Adds simulator URL delivery, prompt handling, and Explore-screen verification.
apps/​AppleApp/​Brownfield-Apple-App-Info.plist Registers the URL scheme and explicit scene configuration.
apps/​AppleApp/​Brownfield Apple App/​BrownfieldAppleApp.swift Forwards SwiftUI URL and universal-link callbacks.
apps/​AppleApp/​Brownfield Apple App.xcodeproj/​project.pbxproj Removes generated scene manifests so the explicit configuration is retained.
.changeset/​ios-scene-warm-links.md Records the linking 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