Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .claude/skills/rnn-codebase/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Each controller type has a Presenter that applies options to views:
| React view rendering | — | `ios/RNNReactView.mm` | `react/ReactView.java` |
| Events to JS | `src/adapters/NativeEventsReceiver.ts` | `ios/RNNEventEmitter.mm` | `react/events/EventEmitter.java` |
| Component registration | `src/components/ComponentRegistry.ts` | — | — |
| Deep linking (URL → screen) | `src/linking/` (`LinkingHandler`, `URLParser`, `RouteMatcher`, `DeferredLinkQueue`, `ModalLayoutBuilder`) | `ios/RNNAppDelegate.mm` (`dispatchDeepLinkURL:`, cold-start queue, `RCTContentDidAppearNotification`) | `NavigationActivity.onNewIntent` → `ReactGateway` |

### By directory

Expand Down Expand Up @@ -149,3 +150,5 @@ API layout → OptionsCrawler.crawl() → LayoutProcessor.process()
- Options that exist in JS types may not be implemented on both platforms — check the presenter
- `passProps` are stored in JS `Store`, not sent to native (cleared before bridge crossing)
- The `lib/` folder is generated — never edit it, edit `src/` instead
- Deep links are processed only after the first `setRoot()` resolves; pre-bridge URLs on iOS are queued natively in `RNNAppDelegate` and flushed on `RCTContentDidAppearNotification` (bridgeless mode — `RCTJavaScriptDidLoadNotification` does NOT fire)
- `ModalLayoutBuilder` strips React-reserved keys (`ref`, `key`) from URL query params before they reach `passProps`, to avoid React 19 ref-validation crashes
4 changes: 4 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ Core commands available through the Navigation API:
- **Modal**: `showModal()`, `dismissModal()`, `dismissAllModals()`
- **Overlay**: `showOverlay()`, `dismissOverlay()`, `dismissAllOverlays()`
- **Options**: `setDefaultOptions()`, `mergeOptions()`, `updateProps()`
- **Linking**: `setLinking()`, `handleDeepLink()`, `setLinkingReady()` - URL-to-screen routing

### Deep Linking
URL-driven navigation is implemented entirely in the JS layer (`src/linking/`) and feeds into the standard command pipeline. Configure with `Navigation.setLinking({ prefixes, config: { screens } })`; matched URLs are presented as modals by default (preserving the user's current navigation state). Customize via `getModal`/`onLink` hooks; gate processing on auth via `isReady`/`setLinkingReady`. Native plumbing — `application:openURL:`, `application:continueUserActivity:`, cold-start URL queueing — lives in `RNNAppDelegate` (iOS) and `NavigationActivity.onNewIntent` (Android), so subclassing the base classes is sufficient. See [Deep Linking docs](https://wix.github.io/react-native-navigation/docs/deep-linking).

### Options System
Styling and behavior is controlled via a hierarchical options object:
Expand Down
5 changes: 5 additions & 0 deletions ios/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ Base class that user's AppDelegate must extend. Handles React Native and navigat
- Creates `RCTRootViewFactory` and `ReactHost`
- Calls `[ReactNativeNavigation bootstrapWithHost:]` to initialize navigation
- Handles RN version differences (0.77, 0.78, 0.79+) via compile-time macros
- **Deep linking plumbing**:
- Implements `application:openURL:options:` and `application:continueUserActivity:restorationHandler:`; both call `-dispatchDeepLinkURL:`.
- `-dispatchDeepLinkURL:` posts `RCTOpenURLNotification` directly if the React runtime is ready, otherwise enqueues the URL.
- Observes `RCTContentDidAppearNotification` (the Fabric/bridgeless signal) to flush the queue. This solves the cold-start race where URLs (push notifications, OS link launches) arrive before `RCTLinkingManager` is listening.
- Subclasses can call `-dispatchDeepLinkURL:` manually from notification delegates or any other URL source; the queueing behavior is reused automatically.

### ReactNativeNavigation Bootstrap
**File**: `ReactNativeNavigation.h/mm`
Expand Down
16 changes: 16 additions & 0 deletions ios/RNNAppDelegate.h
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,20 @@
@property(nonatomic) BOOL bridgelessEnabled;
#endif

/**
* Dispatch a deep link URL through React Native's Linking module so JS
* subscribers (including RNN's built-in deep linking framework) receive it.
*
* Safe to call before the JS bridge is ready: URLs that arrive early
* (e.g. cold-start notification taps) are queued natively and flushed
* automatically once Fabric/React content first appears.
*
* Custom-scheme and universal-link openings dispatched by the OS are
* forwarded through this method automatically; call it manually only
* when your app receives a deep link from a source RNN can't intercept
* (e.g. a custom `UNUserNotificationCenterDelegate`, a third-party push
* SDK callback, etc.).
*/
- (void)dispatchDeepLinkURL:(NSURL *)url;

@end
73 changes: 73 additions & 0 deletions ios/RNNAppDelegate.mm
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
#import <React/RCTCxxBridgeDelegate.h>
#endif
#import <React/RCTLegacyViewManagerInteropComponentView.h>
#import <React/RCTLinkingManager.h>
#import <React/RCTRootView.h>
#import <React/RCTSurfacePresenter.h>
#if __has_include(<React/RCTSurfacePresenterStub.h>)
#import <React/RCTSurfacePresenterStub.h>
Expand Down Expand Up @@ -36,6 +38,13 @@

#import <React/RCTComponentViewFactory.h>

// Deep-link URLs that arrive (openURL, universal link, or external dispatch)
// before the React runtime is ready are queued here and flushed when Fabric
// posts `RCTContentDidAppearNotification` — by which point
// `RCTLinkingManager` is instantiated and JS subscribers are listening.
static NSMutableArray<NSURL *> *gRNNPendingDeepLinkURLs = nil;
static BOOL gRNNReactRuntimeReady = NO;


static NSString *const kRNConcurrentRoot = @"concurrentRoot";

Expand Down Expand Up @@ -92,9 +101,73 @@ - (BOOL)application:(UIApplication *)application
[ReactNativeNavigation bootstrapWithHost:self.reactNativeFactory.rootViewFactory.reactHost];
#endif

[self rnn_installDeepLinkObservers];

return YES;
}

#pragma mark - Deep linking

// Forward OS-delivered custom-scheme URLs to React Native's Linking module.
- (BOOL)application:(UIApplication *)application
openURL:(NSURL *)url
options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options {
[self dispatchDeepLinkURL:url];
return YES;
}

// Forward universal links (associated domains) to React Native's Linking
// module by extracting the underlying https URL and routing it through the
// same pre-bridge queue as everything else.
- (BOOL)application:(UIApplication *)application
continueUserActivity:(NSUserActivity *)userActivity
restorationHandler:
(void (^)(NSArray<id<UIUserActivityRestoring>> *_Nullable))restorationHandler {
if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) {
[self dispatchDeepLinkURL:userActivity.webpageURL];
return YES;
}
return NO;
}

- (void)dispatchDeepLinkURL:(NSURL *)url {
if (url == nil) {
return;
}
if (gRNNReactRuntimeReady) {
[RCTLinkingManager application:[UIApplication sharedApplication]
openURL:url
options:@{}];
return;
}
if (gRNNPendingDeepLinkURLs == nil) {
gRNNPendingDeepLinkURLs = [NSMutableArray array];
}
[gRNNPendingDeepLinkURLs addObject:url];
}

- (void)rnn_installDeepLinkObservers {
// `RCTContentDidAppearNotification` is posted by Fabric's root view
// once content has rendered. RNN forces bridgeless/new-arch, so the
// legacy `RCTJavaScriptDidLoadNotification` never fires; we rely on
// this Fabric signal exclusively.
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(rnn_handleReactRuntimeReady:)
name:RCTContentDidAppearNotification
object:nil];
}

- (void)rnn_handleReactRuntimeReady:(NSNotification *)notification {
gRNNReactRuntimeReady = YES;
NSArray<NSURL *> *pending = [gRNNPendingDeepLinkURLs copy];
[gRNNPendingDeepLinkURLs removeAllObjects];
for (NSURL *url in pending) {
[RCTLinkingManager application:[UIApplication sharedApplication]
openURL:url
options:@{}];
}
}


#if !RNN_RN_VERSION_79_OR_NEWER
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {
Expand Down
6 changes: 6 additions & 0 deletions playground/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="rnnplayground"/>
</intent-filter>
</activity>
</application>
</manifest>
94 changes: 94 additions & 0 deletions playground/e2e/DeepLinking.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import Utils from './Utils';
import TestIDs from '../src/testIDs';

const { elementById, sleep } = Utils;

const NOTIFICATION_PAYLOAD = {
trigger: { type: 'push' },
title: 'Open Pushed Screen',
subtitle: 'Deep link test',
body: 'Tap to open Pushed 42',
badge: 1,
payload: { url: 'rnnplayground://pushed/42' },
'content-available': 0,
'action-identifier': 'default',
};

const NESTED_NOTIFICATION_PAYLOAD = {
...NOTIFICATION_PAYLOAD,
body: 'Tap to open nested Pushed 42 / detail 99',
payload: { url: 'rnnplayground://pushed/42/detail/99' },
};

describe.e2e('Deep linking', () => {
beforeEach(async () => {
await device.launchApp({ newInstance: true });
await elementById(TestIDs.NAVIGATION_TAB).tap();
});

it('deep-link modal can be dismissed via the close button', async () => {
await device.openURL({ url: 'rnnplayground://pushed/42' });
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible();
await elementById(TestIDs.DEEP_LINK_CLOSE_BTN).tap();
await expect(elementById(TestIDs.NAVIGATION_SCREEN)).toBeVisible();
});

it('nested route builds a multi-screen stack inside the modal', async () => {
await elementById(TestIDs.SIMULATE_NESTED_DEEP_LINK_BTN).tap();
// The top-of-stack header proves the second Pushed segment mounted;
// the nested-route -> multi-segment expansion is what produced it.
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible();
});

it('unmatched URL does not present a modal and does not crash', async () => {
await device.openURL({ url: 'rnnplayground://nope' });
await sleep(500);
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeNotVisible();
await expect(elementById(TestIDs.NAVIGATION_SCREEN)).toBeVisible();
});

it('OS-delivered URL while running opens the modal', async () => {
await device.openURL({ url: 'rnnplayground://pushed/77' });
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible();
});

it('OS-delivered URL with query params opens the modal (reserved keys filtered)', async () => {
await device.openURL({ url: 'rnnplayground://pushed/77?ref=test&source=push' });
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible();
});

it('cold-start deep link presents the modal after root mounts', async () => {
await device.launchApp({
newInstance: true,
url: 'rnnplayground://pushed/55',
});
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible();
});

it('tapping a notification with a url payload opens the deep link modal', async () => {
if (device.getPlatform() !== 'ios') {
return;
}
await device.sendUserNotification(NOTIFICATION_PAYLOAD);
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible();
});

it('tapping a notification with a nested url payload builds a multi-screen modal', async () => {
if (device.getPlatform() !== 'ios') {
return;
}
await device.sendUserNotification(NESTED_NOTIFICATION_PAYLOAD);
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible();
});

it('cold-start notification tap presents the modal after root mounts', async () => {
if (device.getPlatform() !== 'ios') {
return;
}
await device.launchApp({
newInstance: true,
userNotification: NOTIFICATION_PAYLOAD,
});
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible();
});
});
49 changes: 45 additions & 4 deletions playground/ios/playground/AppDelegate.mm
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
#import "AppDelegate.h"
#import "RNNCustomViewController.h"
#import <ReactNativeNavigation/ReactNativeNavigation.h>
#import <UserNotifications/UserNotifications.h>

#if !RNN_RN_VERSION_79_OR_NEWER
@interface AppDelegate () <RCTBridgeDelegate>
@interface AppDelegate () <RCTBridgeDelegate, UNUserNotificationCenterDelegate>
@end
#else
@interface AppDelegate ()
@interface AppDelegate () <UNUserNotificationCenterDelegate>
@end

@interface ReactNativeDelegate : RCTDefaultReactNativeFactoryDelegate
Expand Down Expand Up @@ -51,10 +52,51 @@ - (BOOL)application:(UIApplication *)application
callback:^UIViewController *(NSDictionary *props, RCTHost *host) {
return [[RNNCustomViewController alloc] initWithProps:props];
}];


// Demo: route notification taps through RNN's deep-linking pipeline by
// installing this AppDelegate as the UNUserNotificationCenter delegate.
// (Apps that already own this delegate via Firebase/OneSignal/etc. can
// instead call `[self dispatchDeepLinkURL:url]` from their existing
// handler — same effect.)
[UNUserNotificationCenter currentNotificationCenter].delegate = self;

return YES;
}

#pragma mark - UNUserNotificationCenterDelegate

// Surface notifications while the app is in the foreground so the user
// (and Detox) can see them before the tap is simulated.
//
// NOTE: `UNNotificationPresentationOptionAlert` is intentionally included
// alongside the iOS 14+ `.banner`/`.list` flags because Detox checks
// `options.contains(.alert)` in DetoxUserNotificationDispatcher before
// invoking the tap path. Dropping `.alert` would break e2e notification
// tests on Detox even though iOS 14+ otherwise prefers banner/list.
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
willPresentNotification:(UNNotification *)notification
withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler {
completionHandler(UNNotificationPresentationOptionAlert |
UNNotificationPresentationOptionBanner |
UNNotificationPresentationOptionList |
UNNotificationPresentationOptionSound);
}

// Notification tap → if payload carries `url`, route it through RNN's
// deep-linking pipeline (inherited `dispatchDeepLinkURL:` queues until
// the React runtime is ready, then forwards to RCTLinkingManager).
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
didReceiveNotificationResponse:(UNNotificationResponse *)response
withCompletionHandler:(void (^)(void))completionHandler {
NSDictionary *userInfo = response.notification.request.content.userInfo;
NSString *urlString = userInfo[@"url"];
if ([urlString isKindOfClass:[NSString class]]) {
NSURL *url = [NSURL URLWithString:urlString];
[self dispatchDeepLinkURL:url];
}
completionHandler();
}

#if !RNN_RN_VERSION_79_OR_NEWER
#pragma mark - RCTBridgeDelegate
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
Expand All @@ -73,4 +115,3 @@ - (NSURL *)bundleURL
#endif

@end

11 changes: 11 additions & 0 deletions playground/ios/playground/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@
<string>1.0.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.reactnativenavigation.playground</string>
<key>CFBundleURLSchemes</key>
<array>
<string>rnnplayground</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
Expand Down
Loading