Skip to content

Commit 8b56883

Browse files
committed
initial commit
1 parent e4e5049 commit 8b56883

23 files changed

Lines changed: 2036 additions & 4 deletions

playground/android/app/src/main/AndroidManifest.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@
2525
<action android:name="android.intent.action.MAIN"/>
2626
<category android:name="android.intent.category.LAUNCHER"/>
2727
</intent-filter>
28+
<intent-filter>
29+
<action android:name="android.intent.action.VIEW"/>
30+
<category android:name="android.intent.category.DEFAULT"/>
31+
<category android:name="android.intent.category.BROWSABLE"/>
32+
<data android:scheme="rnnplayground"/>
33+
</intent-filter>
2834
</activity>
2935
</application>
3036
</manifest>

playground/e2e/DeepLinking.test.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import Utils from './Utils';
2+
import TestIDs from '../src/testIDs';
3+
4+
const { elementById, sleep } = Utils;
5+
6+
const NOTIFICATION_PAYLOAD = {
7+
trigger: { type: 'push' },
8+
title: 'Open Pushed Screen',
9+
subtitle: 'Deep link test',
10+
body: 'Tap to open Pushed 42',
11+
badge: 1,
12+
payload: { url: 'rnnplayground://pushed/42' },
13+
'content-available': 0,
14+
'action-identifier': 'default',
15+
};
16+
17+
const NESTED_NOTIFICATION_PAYLOAD = {
18+
...NOTIFICATION_PAYLOAD,
19+
body: 'Tap to open nested Pushed 42 / detail 99',
20+
payload: { url: 'rnnplayground://pushed/42/detail/99' },
21+
};
22+
23+
describe.e2e('Deep linking', () => {
24+
beforeEach(async () => {
25+
await device.launchApp({ newInstance: true });
26+
await elementById(TestIDs.NAVIGATION_TAB).tap();
27+
});
28+
29+
it('deep-link modal can be dismissed via the close button', async () => {
30+
await device.openURL({ url: 'rnnplayground://pushed/42' });
31+
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible();
32+
await elementById(TestIDs.DEEP_LINK_CLOSE_BTN).tap();
33+
await expect(elementById(TestIDs.NAVIGATION_SCREEN)).toBeVisible();
34+
});
35+
36+
it('nested route builds a multi-screen stack inside the modal', async () => {
37+
await elementById(TestIDs.SIMULATE_NESTED_DEEP_LINK_BTN).tap();
38+
// The top-of-stack header proves the second Pushed segment mounted;
39+
// the nested-route -> multi-segment expansion is what produced it.
40+
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible();
41+
});
42+
43+
it('unmatched URL does not present a modal and does not crash', async () => {
44+
await device.openURL({ url: 'rnnplayground://nope' });
45+
await sleep(500);
46+
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeNotVisible();
47+
await expect(elementById(TestIDs.NAVIGATION_SCREEN)).toBeVisible();
48+
});
49+
50+
it('OS-delivered URL while running opens the modal', async () => {
51+
await device.openURL({ url: 'rnnplayground://pushed/77' });
52+
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible();
53+
});
54+
55+
it('OS-delivered URL with query params opens the modal (reserved keys filtered)', async () => {
56+
await device.openURL({ url: 'rnnplayground://pushed/77?ref=test&source=push' });
57+
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible();
58+
});
59+
60+
it('cold-start deep link presents the modal after root mounts', async () => {
61+
await device.launchApp({
62+
newInstance: true,
63+
url: 'rnnplayground://pushed/55',
64+
});
65+
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible();
66+
});
67+
68+
it('tapping a notification with a url payload opens the deep link modal', async () => {
69+
if (device.getPlatform() !== 'ios') {
70+
return;
71+
}
72+
await device.sendUserNotification(NOTIFICATION_PAYLOAD);
73+
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible();
74+
});
75+
76+
it('tapping a notification with a nested url payload builds a multi-screen modal', async () => {
77+
if (device.getPlatform() !== 'ios') {
78+
return;
79+
}
80+
await device.sendUserNotification(NESTED_NOTIFICATION_PAYLOAD);
81+
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible();
82+
});
83+
84+
it('cold-start notification tap presents the modal after root mounts', async () => {
85+
if (device.getPlatform() !== 'ios') {
86+
return;
87+
}
88+
await device.launchApp({
89+
newInstance: true,
90+
userNotification: NOTIFICATION_PAYLOAD,
91+
});
92+
await expect(elementById(TestIDs.PUSHED_SCREEN_HEADER)).toBeVisible();
93+
});
94+
});

playground/ios/playground/AppDelegate.mm

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
#import "AppDelegate.h"
22
#import "RNNCustomViewController.h"
33
#import <ReactNativeNavigation/ReactNativeNavigation.h>
4+
#import <React/RCTBridge.h>
5+
#import <React/RCTLinkingManager.h>
6+
#import <React/RCTRootView.h>
7+
#import <UserNotifications/UserNotifications.h>
8+
9+
// URLs that arrive (notification tap, openURL) before the JS bridge is
10+
// ready are queued here and flushed when the bridge finishes loading.
11+
// Without this, cold-start notifications would post `RCTOpenURLNotification`
12+
// into the void because RCTLinkingManager hasn't subscribed yet.
13+
static NSMutableArray<NSURL *> *gPendingDeepLinkURLs = nil;
14+
static BOOL gJavaScriptDidLoad = NO;
415

516
#if !RNN_RN_VERSION_79_OR_NEWER
6-
@interface AppDelegate () <RCTBridgeDelegate>
17+
@interface AppDelegate () <RCTBridgeDelegate, UNUserNotificationCenterDelegate>
718
@end
819
#else
9-
@interface AppDelegate ()
20+
@interface AppDelegate () <UNUserNotificationCenterDelegate>
1021
@end
1122

1223
@interface ReactNativeDelegate : RCTDefaultReactNativeFactoryDelegate
@@ -51,10 +62,109 @@ - (BOOL)application:(UIApplication *)application
5162
callback:^UIViewController *(NSDictionary *props, RCTHost *host) {
5263
return [[RNNCustomViewController alloc] initWithProps:props];
5364
}];
54-
65+
66+
// Receive notification taps (Detox sendUserNotification & real push taps)
67+
[UNUserNotificationCenter currentNotificationCenter].delegate = self;
68+
69+
// Flush deep links that arrived before the JS bridge was up.
70+
// In legacy mode `RCTJavaScriptDidLoadNotification` fires after the
71+
// bridge loads JS. In bridgeless/new-arch that notification does NOT
72+
// fire, so we also listen for `RCTContentDidAppearNotification` which
73+
// is posted by Fabric's root view once content has rendered — by which
74+
// point RCTLinkingManager is already instantiated and listening.
75+
[[NSNotificationCenter defaultCenter] addObserver:self
76+
selector:@selector(handleJavaScriptDidLoad:)
77+
name:RCTJavaScriptDidLoadNotification
78+
object:nil];
79+
[[NSNotificationCenter defaultCenter] addObserver:self
80+
selector:@selector(handleJavaScriptDidLoad:)
81+
name:RCTContentDidAppearNotification
82+
object:nil];
83+
5584
return YES;
5685
}
5786

87+
#pragma mark - Deep linking
88+
89+
// Forward foreground URL openings (custom schemes & universal links)
90+
// to React Native's Linking module so JS can handle them.
91+
- (BOOL)application:(UIApplication *)application
92+
openURL:(NSURL *)url
93+
options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options {
94+
[self dispatchDeepLinkURL:url];
95+
return YES;
96+
}
97+
98+
// Dispatch a deep link URL. If RCTLinkingManager hasn't subscribed yet
99+
// (cold-start, JS bridge still loading), queue it for replay after
100+
// RCTJavaScriptDidLoadNotification fires.
101+
- (void)dispatchDeepLinkURL:(NSURL *)url {
102+
if (url == nil) { return; }
103+
if (gJavaScriptDidLoad) {
104+
[RCTLinkingManager application:[UIApplication sharedApplication]
105+
openURL:url
106+
options:@{}];
107+
return;
108+
}
109+
if (gPendingDeepLinkURLs == nil) {
110+
gPendingDeepLinkURLs = [NSMutableArray array];
111+
}
112+
[gPendingDeepLinkURLs addObject:url];
113+
}
114+
115+
- (void)handleJavaScriptDidLoad:(NSNotification *)notification {
116+
gJavaScriptDidLoad = YES;
117+
NSArray<NSURL *> *pending = [gPendingDeepLinkURLs copy];
118+
[gPendingDeepLinkURLs removeAllObjects];
119+
for (NSURL *url in pending) {
120+
[RCTLinkingManager application:[UIApplication sharedApplication]
121+
openURL:url
122+
options:@{}];
123+
}
124+
}
125+
126+
- (BOOL)application:(UIApplication *)application
127+
continueUserActivity:(NSUserActivity *)userActivity
128+
restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> *))restorationHandler {
129+
return [RCTLinkingManager application:application
130+
continueUserActivity:userActivity
131+
restorationHandler:restorationHandler];
132+
}
133+
134+
#pragma mark - UNUserNotificationCenterDelegate
135+
136+
// Surface notifications while the app is in the foreground so the user
137+
// (and Detox) can see them before the tap is simulated.
138+
//
139+
// NOTE: `UNNotificationPresentationOptionAlert` is intentionally included
140+
// alongside the iOS 14+ `.banner`/`.list` flags because Detox checks
141+
// `options.contains(.alert)` in DetoxUserNotificationDispatcher before
142+
// invoking the tap path. Dropping `.alert` would break e2e notification
143+
// tests on Detox even though iOS 14+ otherwise prefers banner/list.
144+
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
145+
willPresentNotification:(UNNotification *)notification
146+
withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler {
147+
completionHandler(UNNotificationPresentationOptionAlert |
148+
UNNotificationPresentationOptionBanner |
149+
UNNotificationPresentationOptionList |
150+
UNNotificationPresentationOptionSound);
151+
}
152+
153+
// Notification tap → if payload carries `url`, route it through the
154+
// existing Linking pipeline so deep linking reacts the same way regardless
155+
// of whether the URL came from the OS or a notification.
156+
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
157+
didReceiveNotificationResponse:(UNNotificationResponse *)response
158+
withCompletionHandler:(void (^)(void))completionHandler {
159+
NSDictionary *userInfo = response.notification.request.content.userInfo;
160+
NSString *urlString = userInfo[@"url"];
161+
if ([urlString isKindOfClass:[NSString class]]) {
162+
NSURL *url = [NSURL URLWithString:urlString];
163+
[self dispatchDeepLinkURL:url];
164+
}
165+
completionHandler();
166+
}
167+
58168
#if !RNN_RN_VERSION_79_OR_NEWER
59169
#pragma mark - RCTBridgeDelegate
60170
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge

playground/ios/playground/Info.plist

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@
1818
<string>1.0.0</string>
1919
<key>CFBundleSignature</key>
2020
<string>????</string>
21+
<key>CFBundleURLTypes</key>
22+
<array>
23+
<dict>
24+
<key>CFBundleURLName</key>
25+
<string>com.reactnativenavigation.playground</string>
26+
<key>CFBundleURLSchemes</key>
27+
<array>
28+
<string>rnnplayground</string>
29+
</array>
30+
</dict>
31+
</array>
2132
<key>CFBundleVersion</key>
2233
<string>1</string>
2334
<key>LSRequiresIPhoneOS</key>

playground/src/app.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Navigation as RNNavigation } from 'react-native-navigation';
12
import Navigation from './services/Navigation';
23
import { registerScreens } from './screens';
34
import addProcessors from './commons/Processors';
@@ -21,12 +22,75 @@ function start() {
2122
registerScreens();
2223
addProcessors();
2324
setDefaultOptions();
25+
configureDeepLinking();
2426
Navigation.events().registerAppLaunchedListener(async () => {
2527
Navigation.dismissAllModals();
2628
setRoot();
2729
});
2830
}
2931

32+
function configureDeepLinking() {
33+
RNNavigation.setLinking({
34+
prefixes: ['rnnplayground://'],
35+
config: {
36+
screens: {
37+
Pushed: {
38+
path: 'pushed/:id',
39+
screens: {
40+
Pushed: 'detail/:detailId',
41+
},
42+
},
43+
BackButton: 'back-button',
44+
},
45+
},
46+
// Wrap the default modal so the first screen has a close button.
47+
// Without this, a single-segment match would not provide a way to
48+
// dismiss the modal back to the app.
49+
getModal: (match) => ({
50+
stack: {
51+
children: match.path.map((segment, index) => ({
52+
component: {
53+
name: segment.screen,
54+
passProps: filterReservedProps({ ...match.queryParams, ...segment.params }),
55+
options:
56+
index === 0
57+
? {
58+
topBar: {
59+
leftButtons: [
60+
{
61+
id: 'deepLinkClose',
62+
testID: testIDs.DEEP_LINK_CLOSE_BTN,
63+
text: 'Close',
64+
},
65+
],
66+
},
67+
}
68+
: undefined,
69+
},
70+
})),
71+
},
72+
}),
73+
fallback: (url) => {
74+
console.warn('Unmatched deep link:', url);
75+
},
76+
});
77+
78+
RNNavigation.events().registerNavigationButtonPressedListener(({ buttonId, componentId }) => {
79+
if (buttonId === 'deepLinkClose') {
80+
RNNavigation.dismissModal(componentId);
81+
}
82+
});
83+
}
84+
85+
const RESERVED_PROPS = new Set(['ref', 'key']);
86+
function filterReservedProps(props: Record<string, string>): Record<string, string> {
87+
const out: Record<string, string> = {};
88+
Object.keys(props).forEach((k) => {
89+
if (!RESERVED_PROPS.has(k)) out[k] = props[k];
90+
});
91+
return out;
92+
}
93+
3094
function setRoot() {
3195
Navigation.setRoot({
3296
root: {

playground/src/screens/NavigationScreen.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
22
import { Platform } from 'react-native';
33
import {
4+
Navigation as RNNavigation,
45
NavigationComponent,
56
NavigationProps,
67
OptionsModalPresentationStyle,
@@ -22,6 +23,7 @@ const {
2223
PAGE_SHEET_MODAL_BTN,
2324
NAVIGATION_SCREEN,
2425
BACK_BUTTON_SCREEN_BTN,
26+
SIMULATE_NESTED_DEEP_LINK_BTN,
2527
} = testIDs;
2628

2729
interface Props extends NavigationProps {}
@@ -94,6 +96,11 @@ export default class NavigationScreen extends NavigationComponent<Props> {
9496
<Button label="Shared Element (Cocktails)" onPress={this.sharedElement} />
9597
<Button label="Shared Element (Car Dealer)" onPress={this.sharedElementAlt} />
9698
<Button label="Shared Element (ImageGallery)" onPress={this.sharedElementImageGallery} />
99+
<Button
100+
label="Simulate Nested Deep Link"
101+
testID={SIMULATE_NESTED_DEEP_LINK_BTN}
102+
onPress={this.simulateNestedDeepLink}
103+
/>
97104
{Platform.OS === 'ios' && (
98105
<Navigation.TouchablePreview
99106
touchableComponent={Button}
@@ -124,6 +131,8 @@ export default class NavigationScreen extends NavigationComponent<Props> {
124131
sharedElement = () => Navigation.showModal(Screens.CocktailsListScreen);
125132
sharedElementAlt = () => Navigation.push(this, Screens.CarsListScreen);
126133
sharedElementImageGallery = () => Navigation.push(this, Screens.ImageGalleryListScreen);
134+
simulateNestedDeepLink = () =>
135+
RNNavigation.handleDeepLink('rnnplayground://pushed/42/detail/99');
127136
preview = ({ reactTag }: { reactTag: number | null }) => {
128137
if (reactTag === null) {
129138
return;

playground/src/testIDs.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,10 @@ const testIDs = {
263263
// BottomTab Role
264264
BOTTOM_TABS_ROLE_BTN: 'BOTTOM_TABS_ROLE_BTN',
265265
BOTTOM_TABS_ROLE_SEARCH_TAB: 'BOTTOM_TABS_ROLE_SEARCH_TAB',
266+
267+
// Deep Linking
268+
SIMULATE_NESTED_DEEP_LINK_BTN: 'SIMULATE_NESTED_DEEP_LINK_BTN',
269+
DEEP_LINK_CLOSE_BTN: 'DEEP_LINK_CLOSE_BTN',
266270
};
267271

268272
export default testIDs;

0 commit comments

Comments
 (0)