diff --git a/.claude/skills/rnn-codebase/SKILL.md b/.claude/skills/rnn-codebase/SKILL.md index 02a66a0b4f..ddd0a9ce9d 100644 --- a/.claude/skills/rnn-codebase/SKILL.md +++ b/.claude/skills/rnn-codebase/SKILL.md @@ -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 @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c3b6a8bebd..c1387f3976 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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: diff --git a/ios/ARCHITECTURE.md b/ios/ARCHITECTURE.md index 049c6f6580..43a43269ba 100644 --- a/ios/ARCHITECTURE.md +++ b/ios/ARCHITECTURE.md @@ -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` diff --git a/ios/RNNAppDelegate.h b/ios/RNNAppDelegate.h index d67d7ce1b1..d951ed09de 100644 --- a/ios/RNNAppDelegate.h +++ b/ios/RNNAppDelegate.h @@ -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 diff --git a/ios/RNNAppDelegate.mm b/ios/RNNAppDelegate.mm index 5e33fa9e4c..da6914a738 100644 --- a/ios/RNNAppDelegate.mm +++ b/ios/RNNAppDelegate.mm @@ -8,6 +8,8 @@ #import #endif #import +#import +#import #import #if __has_include() #import @@ -36,6 +38,13 @@ #import +// 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 *gRNNPendingDeepLinkURLs = nil; +static BOOL gRNNReactRuntimeReady = NO; + static NSString *const kRNConcurrentRoot = @"concurrentRoot"; @@ -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 *)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> *_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 *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 { diff --git a/playground/android/app/src/main/AndroidManifest.xml b/playground/android/app/src/main/AndroidManifest.xml index 4b0248b55f..a10ba65a27 100644 --- a/playground/android/app/src/main/AndroidManifest.xml +++ b/playground/android/app/src/main/AndroidManifest.xml @@ -25,6 +25,12 @@ + + + + + + diff --git a/playground/e2e/DeepLinking.test.js b/playground/e2e/DeepLinking.test.js new file mode 100644 index 0000000000..242760c311 --- /dev/null +++ b/playground/e2e/DeepLinking.test.js @@ -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(); + }); +}); diff --git a/playground/ios/playground/AppDelegate.mm b/playground/ios/playground/AppDelegate.mm index c4b62a465c..8a31ff8b99 100644 --- a/playground/ios/playground/AppDelegate.mm +++ b/playground/ios/playground/AppDelegate.mm @@ -1,12 +1,13 @@ #import "AppDelegate.h" #import "RNNCustomViewController.h" #import +#import #if !RNN_RN_VERSION_79_OR_NEWER -@interface AppDelegate () +@interface AppDelegate () @end #else -@interface AppDelegate () +@interface AppDelegate () @end @interface ReactNativeDelegate : RCTDefaultReactNativeFactoryDelegate @@ -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 @@ -73,4 +115,3 @@ - (NSURL *)bundleURL #endif @end - diff --git a/playground/ios/playground/Info.plist b/playground/ios/playground/Info.plist index a55cc249cd..64d8b689ec 100644 --- a/playground/ios/playground/Info.plist +++ b/playground/ios/playground/Info.plist @@ -18,6 +18,17 @@ 1.0.0 CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleURLName + com.reactnativenavigation.playground + CFBundleURLSchemes + + rnnplayground + + + CFBundleVersion 1 LSRequiresIPhoneOS diff --git a/playground/src/app.ts b/playground/src/app.ts index e00ec855bd..e8a077eac4 100644 --- a/playground/src/app.ts +++ b/playground/src/app.ts @@ -1,3 +1,4 @@ +import { Navigation as RNNavigation } from 'react-native-navigation'; import Navigation from './services/Navigation'; import { registerScreens } from './screens'; import addProcessors from './commons/Processors'; @@ -21,12 +22,75 @@ function start() { registerScreens(); addProcessors(); setDefaultOptions(); + configureDeepLinking(); Navigation.events().registerAppLaunchedListener(async () => { Navigation.dismissAllModals(); setRoot(); }); } +function configureDeepLinking() { + RNNavigation.setLinking({ + prefixes: ['rnnplayground://'], + config: { + screens: { + Pushed: { + path: 'pushed/:id', + screens: { + Pushed: 'detail/:detailId', + }, + }, + BackButton: 'back-button', + }, + }, + // Wrap the default modal so the first screen has a close button. + // Without this, a single-segment match would not provide a way to + // dismiss the modal back to the app. + getModal: (match) => ({ + stack: { + children: match.path.map((segment, index) => ({ + component: { + name: segment.screen, + passProps: filterReservedProps({ ...match.queryParams, ...segment.params }), + options: + index === 0 + ? { + topBar: { + leftButtons: [ + { + id: 'deepLinkClose', + testID: testIDs.DEEP_LINK_CLOSE_BTN, + text: 'Close', + }, + ], + }, + } + : undefined, + }, + })), + }, + }), + fallback: (url) => { + console.warn('Unmatched deep link:', url); + }, + }); + + RNNavigation.events().registerNavigationButtonPressedListener(({ buttonId, componentId }) => { + if (buttonId === 'deepLinkClose') { + RNNavigation.dismissModal(componentId); + } + }); +} + +const RESERVED_PROPS = new Set(['ref', 'key']); +function filterReservedProps(props: Record): Record { + const out: Record = {}; + Object.keys(props).forEach((k) => { + if (!RESERVED_PROPS.has(k)) out[k] = props[k]; + }); + return out; +} + function setRoot() { Navigation.setRoot({ root: { diff --git a/playground/src/screens/NavigationScreen.tsx b/playground/src/screens/NavigationScreen.tsx index d2e4da5b6c..b366bd6585 100644 --- a/playground/src/screens/NavigationScreen.tsx +++ b/playground/src/screens/NavigationScreen.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Platform } from 'react-native'; import { + Navigation as RNNavigation, NavigationComponent, NavigationProps, OptionsModalPresentationStyle, @@ -22,6 +23,7 @@ const { PAGE_SHEET_MODAL_BTN, NAVIGATION_SCREEN, BACK_BUTTON_SCREEN_BTN, + SIMULATE_NESTED_DEEP_LINK_BTN, } = testIDs; interface Props extends NavigationProps {} @@ -94,6 +96,11 @@ export default class NavigationScreen extends NavigationComponent {