From 8b5688359828d6a9724888408e30e40fd1594844 Mon Sep 17 00:00:00 2001 From: Mark de Vocht Date: Wed, 13 May 2026 11:42:40 +0300 Subject: [PATCH 1/3] initial commit --- .../android/app/src/main/AndroidManifest.xml | 6 + playground/e2e/DeepLinking.test.js | 94 +++++ playground/ios/playground/AppDelegate.mm | 116 +++++- playground/ios/playground/Info.plist | 11 + playground/src/app.ts | 64 ++++ playground/src/screens/NavigationScreen.tsx | 9 + playground/src/testIDs.ts | 4 + src/Navigation.ts | 37 +- src/NavigationDelegate.ts | 22 ++ src/index.ts | 1 + src/linking/DeferredLinkQueue.test.ts | 60 +++ src/linking/DeferredLinkQueue.ts | 55 +++ src/linking/LinkingHandler.test.ts | 332 ++++++++++++++++ src/linking/LinkingHandler.ts | 169 +++++++++ src/linking/ModalLayoutBuilder.test.ts | 105 +++++ src/linking/ModalLayoutBuilder.ts | 60 +++ src/linking/RouteMatcher.test.ts | 128 +++++++ src/linking/RouteMatcher.ts | 126 ++++++ src/linking/URLParser.test.ts | 105 +++++ src/linking/URLParser.ts | 62 +++ src/linking/types.ts | 115 ++++++ website/docs/docs/docs-deep-linking.mdx | 358 ++++++++++++++++++ website/sidebars.js | 1 + 23 files changed, 2036 insertions(+), 4 deletions(-) create mode 100644 playground/e2e/DeepLinking.test.js create mode 100644 src/linking/DeferredLinkQueue.test.ts create mode 100644 src/linking/DeferredLinkQueue.ts create mode 100644 src/linking/LinkingHandler.test.ts create mode 100644 src/linking/LinkingHandler.ts create mode 100644 src/linking/ModalLayoutBuilder.test.ts create mode 100644 src/linking/ModalLayoutBuilder.ts create mode 100644 src/linking/RouteMatcher.test.ts create mode 100644 src/linking/RouteMatcher.ts create mode 100644 src/linking/URLParser.test.ts create mode 100644 src/linking/URLParser.ts create mode 100644 src/linking/types.ts create mode 100644 website/docs/docs/docs-deep-linking.mdx diff --git a/playground/android/app/src/main/AndroidManifest.xml b/playground/android/app/src/main/AndroidManifest.xml index 4b0248b55f1..a10ba65a27e 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 00000000000..242760c3111 --- /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 c4b62a465c6..a2eba733d16 100644 --- a/playground/ios/playground/AppDelegate.mm +++ b/playground/ios/playground/AppDelegate.mm @@ -1,12 +1,23 @@ #import "AppDelegate.h" #import "RNNCustomViewController.h" #import +#import +#import +#import +#import + +// URLs that arrive (notification tap, openURL) before the JS bridge is +// ready are queued here and flushed when the bridge finishes loading. +// Without this, cold-start notifications would post `RCTOpenURLNotification` +// into the void because RCTLinkingManager hasn't subscribed yet. +static NSMutableArray *gPendingDeepLinkURLs = nil; +static BOOL gJavaScriptDidLoad = NO; #if !RNN_RN_VERSION_79_OR_NEWER -@interface AppDelegate () +@interface AppDelegate () @end #else -@interface AppDelegate () +@interface AppDelegate () @end @interface ReactNativeDelegate : RCTDefaultReactNativeFactoryDelegate @@ -51,10 +62,109 @@ - (BOOL)application:(UIApplication *)application callback:^UIViewController *(NSDictionary *props, RCTHost *host) { return [[RNNCustomViewController alloc] initWithProps:props]; }]; - + + // Receive notification taps (Detox sendUserNotification & real push taps) + [UNUserNotificationCenter currentNotificationCenter].delegate = self; + + // Flush deep links that arrived before the JS bridge was up. + // In legacy mode `RCTJavaScriptDidLoadNotification` fires after the + // bridge loads JS. In bridgeless/new-arch that notification does NOT + // fire, so we also listen for `RCTContentDidAppearNotification` which + // is posted by Fabric's root view once content has rendered — by which + // point RCTLinkingManager is already instantiated and listening. + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleJavaScriptDidLoad:) + name:RCTJavaScriptDidLoadNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleJavaScriptDidLoad:) + name:RCTContentDidAppearNotification + object:nil]; + return YES; } +#pragma mark - Deep linking + +// Forward foreground URL openings (custom schemes & universal links) +// to React Native's Linking module so JS can handle them. +- (BOOL)application:(UIApplication *)application + openURL:(NSURL *)url + options:(NSDictionary *)options { + [self dispatchDeepLinkURL:url]; + return YES; +} + +// Dispatch a deep link URL. If RCTLinkingManager hasn't subscribed yet +// (cold-start, JS bridge still loading), queue it for replay after +// RCTJavaScriptDidLoadNotification fires. +- (void)dispatchDeepLinkURL:(NSURL *)url { + if (url == nil) { return; } + if (gJavaScriptDidLoad) { + [RCTLinkingManager application:[UIApplication sharedApplication] + openURL:url + options:@{}]; + return; + } + if (gPendingDeepLinkURLs == nil) { + gPendingDeepLinkURLs = [NSMutableArray array]; + } + [gPendingDeepLinkURLs addObject:url]; +} + +- (void)handleJavaScriptDidLoad:(NSNotification *)notification { + gJavaScriptDidLoad = YES; + NSArray *pending = [gPendingDeepLinkURLs copy]; + [gPendingDeepLinkURLs removeAllObjects]; + for (NSURL *url in pending) { + [RCTLinkingManager application:[UIApplication sharedApplication] + openURL:url + options:@{}]; + } +} + +- (BOOL)application:(UIApplication *)application +continueUserActivity:(NSUserActivity *)userActivity + restorationHandler:(void (^)(NSArray> *))restorationHandler { + return [RCTLinkingManager application:application + continueUserActivity:userActivity + restorationHandler:restorationHandler]; +} + +#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 the +// existing Linking pipeline so deep linking reacts the same way regardless +// of whether the URL came from the OS or a notification. +- (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 diff --git a/playground/ios/playground/Info.plist b/playground/ios/playground/Info.plist index a55cc249cd1..64d8b689ec8 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 e00ec855bd5..e8a077eac4e 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 d2e4da5b6c7..b366bd6585b 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 {