react-native-navigation 8.8.6 → 8.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/reactnativenavigation/NavigationApplication.java +3 -0
- package/android/src/main/java/com/reactnativenavigation/NavigationPackage.kt +27 -8
- package/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRow.kt +262 -0
- package/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowAttacher.kt +205 -0
- package/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowConfigStore.kt +32 -0
- package/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowLayout.kt +139 -0
- package/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowModule.kt +37 -0
- package/android/src/main/java/com/reactnativenavigation/customrow/BottomTabsCustomRowOptions.kt +68 -0
- package/android/src/main/java/com/reactnativenavigation/options/BottomTabOptions.java +4 -1
- package/android/src/main/java/com/reactnativenavigation/react/ReactView.java +13 -0
- package/android/src/main/java/com/reactnativenavigation/react/events/ComponentType.java +2 -1
- package/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabPresenter.java +28 -0
- package/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsController.java +59 -0
- package/android/src/main/java/com/reactnativenavigation/views/bottomtabs/BottomTabs.java +76 -0
- package/android/src/main/java/com/reactnativenavigation/views/bottomtabs/CustomBottomTabItemView.kt +73 -0
- package/android/src/main/java/com/reactnativenavigation/views/stack/topbar/titlebar/TitleBarReactButtonView.java +27 -11
- package/android/src/test/java/com/reactnativenavigation/views/TitleAndButtonsContainerTest.kt +15 -1
- package/android/src/test/java/com/reactnativenavigation/views/TitleBarReactButtonViewTest.java +135 -0
- package/ios/ARCHITECTURE.md +5 -0
- package/ios/BottomTabPresenter.h +7 -0
- package/ios/BottomTabPresenter.mm +27 -0
- package/ios/RNNAppDelegate.h +16 -0
- package/ios/RNNAppDelegate.mm +73 -0
- package/ios/RNNBottomTabOptions.h +2 -0
- package/ios/RNNBottomTabOptions.mm +5 -1
- package/ios/RNNBottomTabsController.h +2 -0
- package/ios/RNNBottomTabsController.mm +209 -1
- package/ios/RNNBottomTabsCustomRow.h +57 -0
- package/ios/RNNBottomTabsCustomRow.mm +252 -0
- package/ios/RNNBottomTabsCustomRowOptions.h +42 -0
- package/ios/RNNBottomTabsCustomRowOptions.mm +37 -0
- package/ios/RNNBottomTabsOptions.h +2 -0
- package/ios/RNNBottomTabsOptions.mm +2 -0
- package/ios/RNNComponentViewCreator.h +2 -1
- package/ios/RNNCustomTabBarItemView.h +26 -0
- package/ios/RNNCustomTabBarItemView.mm +83 -0
- package/ios/RNNReactRootViewCreator.mm +1 -0
- package/ios/RNNViewControllerFactory.mm +1 -0
- package/ios/ReactNativeNavigation.xcodeproj/project.pbxproj +24 -0
- package/lib/module/ARCHITECTURE.md +30 -0
- package/lib/module/Navigation.js +34 -1
- package/lib/module/Navigation.js.map +1 -1
- package/lib/module/NavigationDelegate.js +21 -0
- package/lib/module/NavigationDelegate.js.map +1 -1
- package/lib/module/adapters/AndroidCustomRowForwarder.js +75 -0
- package/lib/module/adapters/AndroidCustomRowForwarder.js.map +1 -0
- package/lib/module/commands/Commands.js +8 -0
- package/lib/module/commands/Commands.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/interfaces/Options.js.map +1 -1
- package/lib/module/linking/DeferredLinkQueue.js +52 -0
- package/lib/module/linking/DeferredLinkQueue.js.map +1 -0
- package/lib/module/linking/DeferredLinkQueue.test.js +54 -0
- package/lib/module/linking/DeferredLinkQueue.test.js.map +1 -0
- package/lib/module/linking/LinkingHandler.js +139 -0
- package/lib/module/linking/LinkingHandler.js.map +1 -0
- package/lib/module/linking/LinkingHandler.test.js +384 -0
- package/lib/module/linking/LinkingHandler.test.js.map +1 -0
- package/lib/module/linking/ModalLayoutBuilder.js +56 -0
- package/lib/module/linking/ModalLayoutBuilder.js.map +1 -0
- package/lib/module/linking/ModalLayoutBuilder.test.js +154 -0
- package/lib/module/linking/ModalLayoutBuilder.test.js.map +1 -0
- package/lib/module/linking/RouteMatcher.js +104 -0
- package/lib/module/linking/RouteMatcher.js.map +1 -0
- package/lib/module/linking/RouteMatcher.test.js +164 -0
- package/lib/module/linking/RouteMatcher.test.js.map +1 -0
- package/lib/module/linking/URLParser.js +56 -0
- package/lib/module/linking/URLParser.js.map +1 -0
- package/lib/module/linking/URLParser.test.js +100 -0
- package/lib/module/linking/URLParser.test.js.map +1 -0
- package/lib/module/linking/types.js +4 -0
- package/lib/module/linking/types.js.map +1 -0
- package/lib/typescript/Navigation.d.ts +22 -0
- package/lib/typescript/Navigation.d.ts.map +1 -1
- package/lib/typescript/NavigationDelegate.d.ts +13 -0
- package/lib/typescript/NavigationDelegate.d.ts.map +1 -1
- package/lib/typescript/adapters/AndroidCustomRowForwarder.d.ts +23 -0
- package/lib/typescript/adapters/AndroidCustomRowForwarder.d.ts.map +1 -0
- package/lib/typescript/commands/Commands.d.ts +1 -0
- package/lib/typescript/commands/Commands.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +1 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/interfaces/Options.d.ts +85 -0
- package/lib/typescript/interfaces/Options.d.ts.map +1 -1
- package/lib/typescript/linking/DeferredLinkQueue.d.ts +26 -0
- package/lib/typescript/linking/DeferredLinkQueue.d.ts.map +1 -0
- package/lib/typescript/linking/DeferredLinkQueue.test.d.ts +2 -0
- package/lib/typescript/linking/DeferredLinkQueue.test.d.ts.map +1 -0
- package/lib/typescript/linking/LinkingHandler.d.ts +71 -0
- package/lib/typescript/linking/LinkingHandler.d.ts.map +1 -0
- package/lib/typescript/linking/LinkingHandler.test.d.ts +2 -0
- package/lib/typescript/linking/LinkingHandler.test.d.ts.map +1 -0
- package/lib/typescript/linking/ModalLayoutBuilder.d.ts +21 -0
- package/lib/typescript/linking/ModalLayoutBuilder.d.ts.map +1 -0
- package/lib/typescript/linking/ModalLayoutBuilder.test.d.ts +2 -0
- package/lib/typescript/linking/ModalLayoutBuilder.test.d.ts.map +1 -0
- package/lib/typescript/linking/RouteMatcher.d.ts +23 -0
- package/lib/typescript/linking/RouteMatcher.d.ts.map +1 -0
- package/lib/typescript/linking/RouteMatcher.test.d.ts +2 -0
- package/lib/typescript/linking/RouteMatcher.test.d.ts.map +1 -0
- package/lib/typescript/linking/URLParser.d.ts +16 -0
- package/lib/typescript/linking/URLParser.d.ts.map +1 -0
- package/lib/typescript/linking/URLParser.test.d.ts +2 -0
- package/lib/typescript/linking/URLParser.test.d.ts.map +1 -0
- package/lib/typescript/linking/types.d.ts +107 -0
- package/lib/typescript/linking/types.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/ARCHITECTURE.md +30 -0
- package/src/Navigation.ts +36 -1
- package/src/NavigationDelegate.ts +22 -0
- package/src/adapters/AndroidCustomRowForwarder.ts +83 -0
- package/src/commands/Commands.ts +15 -0
- package/src/index.ts +1 -0
- package/src/interfaces/Options.ts +87 -0
- package/src/linking/DeferredLinkQueue.test.ts +60 -0
- package/src/linking/DeferredLinkQueue.ts +55 -0
- package/src/linking/LinkingHandler.test.ts +332 -0
- package/src/linking/LinkingHandler.ts +169 -0
- package/src/linking/ModalLayoutBuilder.test.ts +105 -0
- package/src/linking/ModalLayoutBuilder.ts +60 -0
- package/src/linking/RouteMatcher.test.ts +128 -0
- package/src/linking/RouteMatcher.ts +126 -0
- package/src/linking/URLParser.test.ts +105 -0
- package/src/linking/URLParser.ts +62 -0
- package/src/linking/types.ts +115 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { LinkingHandler, LinkingAPI } from './LinkingHandler';
|
|
2
|
+
import { LinkingConfig, RouteMatch } from './types';
|
|
3
|
+
import { Layout } from '../interfaces/Layout';
|
|
4
|
+
|
|
5
|
+
describe('LinkingHandler', () => {
|
|
6
|
+
let uut: LinkingHandler;
|
|
7
|
+
let mockShowModal: jest.Mock<Promise<string>, [Layout]>;
|
|
8
|
+
let mockLinking: jest.Mocked<LinkingAPI>;
|
|
9
|
+
let urlListener: ((event: { url: string }) => void) | null;
|
|
10
|
+
|
|
11
|
+
const baseConfig: LinkingConfig = {
|
|
12
|
+
prefixes: ['myapp://', 'https://myapp.com'],
|
|
13
|
+
config: {
|
|
14
|
+
screens: {
|
|
15
|
+
Home: 'home',
|
|
16
|
+
Profile: 'user/:id',
|
|
17
|
+
Settings: {
|
|
18
|
+
path: 'settings',
|
|
19
|
+
screens: { Notifications: 'notifications' },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
mockShowModal = jest.fn().mockResolvedValue('modalId');
|
|
27
|
+
urlListener = null;
|
|
28
|
+
mockLinking = {
|
|
29
|
+
addEventListener: jest.fn((_type, handler) => {
|
|
30
|
+
urlListener = handler;
|
|
31
|
+
return { remove: jest.fn() };
|
|
32
|
+
}),
|
|
33
|
+
getInitialURL: jest.fn().mockResolvedValue(null),
|
|
34
|
+
} as unknown as jest.Mocked<LinkingAPI>;
|
|
35
|
+
uut = new LinkingHandler(mockShowModal, mockLinking);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
uut.teardown();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('resolve', () => {
|
|
43
|
+
beforeEach(() => uut.configure(baseConfig));
|
|
44
|
+
|
|
45
|
+
it('resolves a simple URL to a route match', () => {
|
|
46
|
+
expect(uut.resolve('myapp://home')).toEqual({
|
|
47
|
+
url: 'myapp://home',
|
|
48
|
+
path: [{ screen: 'Home', params: {} }],
|
|
49
|
+
queryParams: {},
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('resolves a URL with path params', () => {
|
|
54
|
+
expect(uut.resolve('myapp://user/42')).toEqual({
|
|
55
|
+
url: 'myapp://user/42',
|
|
56
|
+
path: [{ screen: 'Profile', params: { id: '42' } }],
|
|
57
|
+
queryParams: {},
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('resolves a nested route as a chain', () => {
|
|
62
|
+
const match = uut.resolve('myapp://settings/notifications');
|
|
63
|
+
expect(match?.path).toEqual([
|
|
64
|
+
{ screen: 'Settings', params: {} },
|
|
65
|
+
{ screen: 'Notifications', params: {} },
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns null when the prefix does not match', () => {
|
|
70
|
+
expect(uut.resolve('other://home')).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns null when no route matches', () => {
|
|
74
|
+
expect(uut.resolve('myapp://unknown')).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns null before configure() is called', () => {
|
|
78
|
+
const fresh = new LinkingHandler(mockShowModal, mockLinking);
|
|
79
|
+
expect(fresh.resolve('myapp://home')).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('default modal presentation', () => {
|
|
84
|
+
it('defers links until rootReady is signalled, then auto-flushes', () => {
|
|
85
|
+
uut.configure(baseConfig);
|
|
86
|
+
uut.handleURL('myapp://home');
|
|
87
|
+
expect(mockShowModal).not.toHaveBeenCalled();
|
|
88
|
+
uut.setRootReady();
|
|
89
|
+
expect(mockShowModal).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(mockShowModal).toHaveBeenCalledWith({
|
|
91
|
+
stack: {
|
|
92
|
+
children: [{ component: { name: 'Home', passProps: {} } }],
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('processes links immediately once rootReady', () => {
|
|
98
|
+
uut.configure(baseConfig);
|
|
99
|
+
uut.setRootReady();
|
|
100
|
+
uut.handleURL('myapp://user/42');
|
|
101
|
+
expect(mockShowModal).toHaveBeenCalledWith({
|
|
102
|
+
stack: {
|
|
103
|
+
children: [{ component: { name: 'Profile', passProps: { id: '42' } } }],
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('builds a push chain inside the modal stack for nested matches', () => {
|
|
109
|
+
uut.configure(baseConfig);
|
|
110
|
+
uut.setRootReady();
|
|
111
|
+
uut.handleURL('myapp://settings/notifications');
|
|
112
|
+
expect(mockShowModal).toHaveBeenCalledWith({
|
|
113
|
+
stack: {
|
|
114
|
+
children: [
|
|
115
|
+
{ component: { name: 'Settings', passProps: {} } },
|
|
116
|
+
{ component: { name: 'Notifications', passProps: {} } },
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('merges query params into passProps', () => {
|
|
123
|
+
uut.configure(baseConfig);
|
|
124
|
+
uut.setRootReady();
|
|
125
|
+
uut.handleURL('myapp://user/42?source=push');
|
|
126
|
+
expect(mockShowModal).toHaveBeenCalledWith({
|
|
127
|
+
stack: {
|
|
128
|
+
children: [
|
|
129
|
+
{
|
|
130
|
+
component: {
|
|
131
|
+
name: 'Profile',
|
|
132
|
+
passProps: { id: '42', source: 'push' },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('filters React-reserved query params from passProps', () => {
|
|
141
|
+
uut.configure(baseConfig);
|
|
142
|
+
uut.setRootReady();
|
|
143
|
+
uut.handleURL('myapp://user/42?ref=push&key=abc&utm=foo');
|
|
144
|
+
expect(mockShowModal).toHaveBeenCalledWith({
|
|
145
|
+
stack: {
|
|
146
|
+
children: [
|
|
147
|
+
{
|
|
148
|
+
component: {
|
|
149
|
+
name: 'Profile',
|
|
150
|
+
passProps: { id: '42', utm: 'foo' },
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('getModal override', () => {
|
|
160
|
+
it('uses the supplied builder instead of the default', () => {
|
|
161
|
+
const customLayout: Layout = {
|
|
162
|
+
component: { name: 'Custom' },
|
|
163
|
+
};
|
|
164
|
+
const getModal = jest.fn().mockReturnValue(customLayout);
|
|
165
|
+
uut.configure({ ...baseConfig, getModal });
|
|
166
|
+
uut.setRootReady();
|
|
167
|
+
uut.handleURL('myapp://home');
|
|
168
|
+
expect(getModal).toHaveBeenCalledTimes(1);
|
|
169
|
+
const passedMatch = getModal.mock.calls[0][0];
|
|
170
|
+
expect(passedMatch.path).toEqual([{ screen: 'Home', params: {} }]);
|
|
171
|
+
expect(mockShowModal).toHaveBeenCalledWith(customLayout);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('skips presentation when getModal returns undefined', () => {
|
|
175
|
+
const getModal = jest.fn().mockReturnValue(undefined);
|
|
176
|
+
uut.configure({ ...baseConfig, getModal });
|
|
177
|
+
uut.setRootReady();
|
|
178
|
+
uut.handleURL('myapp://home');
|
|
179
|
+
expect(getModal).toHaveBeenCalled();
|
|
180
|
+
expect(mockShowModal).not.toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('onLink override', () => {
|
|
185
|
+
it('calls onLink and bypasses showModal entirely', () => {
|
|
186
|
+
const onLink = jest.fn();
|
|
187
|
+
uut.configure({ ...baseConfig, onLink });
|
|
188
|
+
uut.setRootReady();
|
|
189
|
+
uut.handleURL('myapp://user/42');
|
|
190
|
+
expect(onLink).toHaveBeenCalledTimes(1);
|
|
191
|
+
const passedMatch = onLink.mock.calls[0][0];
|
|
192
|
+
expect(passedMatch.path).toEqual([{ screen: 'Profile', params: { id: '42' } }]);
|
|
193
|
+
expect(mockShowModal).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('takes precedence over getModal', () => {
|
|
197
|
+
const onLink = jest.fn();
|
|
198
|
+
const getModal = jest.fn();
|
|
199
|
+
uut.configure({ ...baseConfig, onLink, getModal });
|
|
200
|
+
uut.setRootReady();
|
|
201
|
+
uut.handleURL('myapp://home');
|
|
202
|
+
expect(onLink).toHaveBeenCalled();
|
|
203
|
+
expect(getModal).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('fallback', () => {
|
|
208
|
+
it('is called when no prefix matches', () => {
|
|
209
|
+
const fallback = jest.fn();
|
|
210
|
+
uut.configure({ ...baseConfig, fallback });
|
|
211
|
+
uut.setRootReady();
|
|
212
|
+
uut.handleURL('other://home');
|
|
213
|
+
expect(fallback).toHaveBeenCalledWith('other://home');
|
|
214
|
+
expect(mockShowModal).not.toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('is called when no route matches', () => {
|
|
218
|
+
const fallback = jest.fn();
|
|
219
|
+
uut.configure({ ...baseConfig, fallback });
|
|
220
|
+
uut.setRootReady();
|
|
221
|
+
uut.handleURL('myapp://unknown');
|
|
222
|
+
expect(fallback).toHaveBeenCalledWith('myapp://unknown');
|
|
223
|
+
expect(mockShowModal).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('is silent when not supplied', () => {
|
|
227
|
+
uut.configure(baseConfig);
|
|
228
|
+
uut.setRootReady();
|
|
229
|
+
expect(() => uut.handleURL('myapp://unknown')).not.toThrow();
|
|
230
|
+
expect(mockShowModal).not.toHaveBeenCalled();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('isReady predicate', () => {
|
|
235
|
+
it('queues links while isReady returns false', () => {
|
|
236
|
+
let ready = false;
|
|
237
|
+
uut.configure({ ...baseConfig, isReady: () => ready });
|
|
238
|
+
uut.setRootReady();
|
|
239
|
+
uut.handleURL('myapp://home');
|
|
240
|
+
expect(mockShowModal).not.toHaveBeenCalled();
|
|
241
|
+
ready = true;
|
|
242
|
+
uut.setLinkingReady(true);
|
|
243
|
+
expect(mockShowModal).toHaveBeenCalledTimes(1);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('setLinkingReady(true) overrides a false isReady', () => {
|
|
247
|
+
uut.configure({ ...baseConfig, isReady: () => false });
|
|
248
|
+
uut.setRootReady();
|
|
249
|
+
uut.setLinkingReady(true);
|
|
250
|
+
uut.handleURL('myapp://home');
|
|
251
|
+
expect(mockShowModal).toHaveBeenCalled();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('setLinkingReady(false) blocks even when isReady is absent', () => {
|
|
255
|
+
uut.configure(baseConfig);
|
|
256
|
+
uut.setRootReady();
|
|
257
|
+
uut.setLinkingReady(false);
|
|
258
|
+
uut.handleURL('myapp://home');
|
|
259
|
+
expect(mockShowModal).not.toHaveBeenCalled();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('Linking subscription', () => {
|
|
264
|
+
it('dispatches URL events through the pipeline', () => {
|
|
265
|
+
uut.configure(baseConfig);
|
|
266
|
+
uut.setRootReady();
|
|
267
|
+
urlListener?.({ url: 'myapp://home' });
|
|
268
|
+
expect(mockShowModal).toHaveBeenCalled();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('reads the initial URL on configure', async () => {
|
|
272
|
+
mockLinking.getInitialURL.mockResolvedValueOnce('myapp://home');
|
|
273
|
+
uut.configure(baseConfig);
|
|
274
|
+
uut.setRootReady();
|
|
275
|
+
await Promise.resolve();
|
|
276
|
+
await Promise.resolve();
|
|
277
|
+
expect(mockShowModal).toHaveBeenCalled();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('removes the listener on teardown', () => {
|
|
281
|
+
const remove = jest.fn();
|
|
282
|
+
(mockLinking.addEventListener as jest.Mock).mockReturnValueOnce({ remove });
|
|
283
|
+
uut.configure(baseConfig);
|
|
284
|
+
uut.teardown();
|
|
285
|
+
expect(remove).toHaveBeenCalled();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('reconfiguring tears down the previous subscription', () => {
|
|
289
|
+
const remove = jest.fn();
|
|
290
|
+
(mockLinking.addEventListener as jest.Mock).mockReturnValueOnce({ remove });
|
|
291
|
+
uut.configure(baseConfig);
|
|
292
|
+
uut.configure(baseConfig);
|
|
293
|
+
expect(remove).toHaveBeenCalled();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('setRootReady', () => {
|
|
298
|
+
it('is idempotent', () => {
|
|
299
|
+
uut.configure(baseConfig);
|
|
300
|
+
uut.handleURL('myapp://home');
|
|
301
|
+
uut.setRootReady();
|
|
302
|
+
uut.setRootReady();
|
|
303
|
+
expect(mockShowModal).toHaveBeenCalledTimes(1);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('persists across reconfigure', () => {
|
|
307
|
+
uut.setRootReady();
|
|
308
|
+
uut.configure(baseConfig);
|
|
309
|
+
uut.handleURL('myapp://home');
|
|
310
|
+
expect(mockShowModal).toHaveBeenCalled();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe('match payload', () => {
|
|
315
|
+
it('passes the full match object to onLink', () => {
|
|
316
|
+
let captured: RouteMatch | null = null;
|
|
317
|
+
uut.configure({
|
|
318
|
+
...baseConfig,
|
|
319
|
+
onLink: (match) => {
|
|
320
|
+
captured = match;
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
uut.setRootReady();
|
|
324
|
+
uut.handleURL('myapp://user/42?source=push');
|
|
325
|
+
expect(captured).toEqual({
|
|
326
|
+
url: 'myapp://user/42?source=push',
|
|
327
|
+
path: [{ screen: 'Profile', params: { id: '42' } }],
|
|
328
|
+
queryParams: { source: 'push' },
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Linking } from 'react-native';
|
|
2
|
+
import { Layout } from '../interfaces/Layout';
|
|
3
|
+
import { URLParser } from './URLParser';
|
|
4
|
+
import { RouteMatcher } from './RouteMatcher';
|
|
5
|
+
import { DeferredLinkQueue } from './DeferredLinkQueue';
|
|
6
|
+
import { ModalLayoutBuilder } from './ModalLayoutBuilder';
|
|
7
|
+
import { LinkingConfig, RouteMatch } from './types';
|
|
8
|
+
|
|
9
|
+
/** A function that shows a layout as a modal. Mirrors `Commands.showModal`. */
|
|
10
|
+
export type ShowModal = (layout: Layout) => Promise<string>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Minimal abstraction over React Native's `Linking` module so the handler
|
|
14
|
+
* can be tested without touching native bindings.
|
|
15
|
+
*/
|
|
16
|
+
export interface LinkingAPI {
|
|
17
|
+
addEventListener(
|
|
18
|
+
type: 'url',
|
|
19
|
+
handler: (event: { url: string }) => void
|
|
20
|
+
): { remove: () => void };
|
|
21
|
+
getInitialURL(): Promise<string | null>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Orchestrates deep linking:
|
|
26
|
+
*
|
|
27
|
+
* 1. Subscribes to URL events from `Linking` and the initial URL.
|
|
28
|
+
* 2. Parses + matches each URL against the configured `screens` tree.
|
|
29
|
+
* 3. Defers processing until both:
|
|
30
|
+
* - the first `setRoot` has resolved (so a modal can be presented), and
|
|
31
|
+
* - the user-supplied `isReady` predicate (if any) returns `true`.
|
|
32
|
+
* 4. Resolves matches in this order:
|
|
33
|
+
* - `onLink(match)` if provided (full escape hatch), else
|
|
34
|
+
* - `getModal(match)` if provided, else
|
|
35
|
+
* - the default `ModalLayoutBuilder` output.
|
|
36
|
+
* Result is presented via `showModal`.
|
|
37
|
+
* 5. Calls `fallback(url)` when a URL has no matching prefix or route.
|
|
38
|
+
*/
|
|
39
|
+
export class LinkingHandler {
|
|
40
|
+
private readonly urlParser: URLParser;
|
|
41
|
+
private readonly routeMatcher: RouteMatcher;
|
|
42
|
+
private readonly deferredQueue: DeferredLinkQueue;
|
|
43
|
+
private readonly modalLayoutBuilder: ModalLayoutBuilder;
|
|
44
|
+
private readonly showModal: ShowModal;
|
|
45
|
+
private readonly linkingAPI: LinkingAPI;
|
|
46
|
+
|
|
47
|
+
private config: LinkingConfig | null = null;
|
|
48
|
+
private linkingSubscription: { remove: () => void } | null = null;
|
|
49
|
+
private rootReady = false;
|
|
50
|
+
private userReadyOverride: boolean | null = null;
|
|
51
|
+
|
|
52
|
+
constructor(showModal: ShowModal, linkingAPI?: LinkingAPI) {
|
|
53
|
+
this.urlParser = new URLParser();
|
|
54
|
+
this.routeMatcher = new RouteMatcher();
|
|
55
|
+
this.deferredQueue = new DeferredLinkQueue();
|
|
56
|
+
this.modalLayoutBuilder = new ModalLayoutBuilder();
|
|
57
|
+
this.showModal = showModal;
|
|
58
|
+
this.linkingAPI = linkingAPI || (Linking as unknown as LinkingAPI);
|
|
59
|
+
this.deferredQueue.setFlushCallback((url) => this.processURL(url));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public configure(config: LinkingConfig): void {
|
|
63
|
+
this.teardown();
|
|
64
|
+
this.config = config;
|
|
65
|
+
|
|
66
|
+
const tree = this.routeMatcher.buildRouteTree(config.config.screens);
|
|
67
|
+
this.routeMatcher.setRouteTree(tree);
|
|
68
|
+
|
|
69
|
+
this.refreshReady();
|
|
70
|
+
this.subscribe();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Manually feed a URL into the pipeline as if it came from the OS.
|
|
75
|
+
* Useful for URLs received from push notifications or other sources.
|
|
76
|
+
*/
|
|
77
|
+
public handleURL(url: string): void {
|
|
78
|
+
if (!this.config) return;
|
|
79
|
+
if (!this.isReady()) {
|
|
80
|
+
this.deferredQueue.enqueue(url);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.processURL(url);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Called by `NavigationRoot` after the first `setRoot` resolves. Subsequent
|
|
88
|
+
* calls are no-ops.
|
|
89
|
+
*/
|
|
90
|
+
public setRootReady(): void {
|
|
91
|
+
if (this.rootReady) return;
|
|
92
|
+
this.rootReady = true;
|
|
93
|
+
this.refreshReady();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Public toggle for the app-supplied readiness gate. Overrides the
|
|
98
|
+
* `config.isReady` predicate from the moment it's first called.
|
|
99
|
+
*/
|
|
100
|
+
public setLinkingReady(ready: boolean): void {
|
|
101
|
+
this.userReadyOverride = ready;
|
|
102
|
+
this.refreshReady();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve a URL to a `RouteMatch` without side effects. Returns `null`
|
|
107
|
+
* when the URL doesn't match any configured prefix or route.
|
|
108
|
+
*/
|
|
109
|
+
public resolve(url: string): RouteMatch | null {
|
|
110
|
+
if (!this.config) return null;
|
|
111
|
+
const parsed = this.urlParser.parse(url, this.config.prefixes);
|
|
112
|
+
if (!parsed) return null;
|
|
113
|
+
return this.routeMatcher.match(parsed.path, url, parsed.queryParams);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
public teardown(): void {
|
|
117
|
+
if (this.linkingSubscription) {
|
|
118
|
+
this.linkingSubscription.remove();
|
|
119
|
+
this.linkingSubscription = null;
|
|
120
|
+
}
|
|
121
|
+
this.deferredQueue.clear();
|
|
122
|
+
this.config = null;
|
|
123
|
+
this.userReadyOverride = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private subscribe(): void {
|
|
127
|
+
this.linkingSubscription = this.linkingAPI.addEventListener('url', (event) => {
|
|
128
|
+
this.handleURL(event.url);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
this.linkingAPI.getInitialURL().then((url) => {
|
|
132
|
+
if (url) this.handleURL(url);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private processURL(url: string): void {
|
|
137
|
+
if (!this.config) return;
|
|
138
|
+
|
|
139
|
+
const match = this.resolve(url);
|
|
140
|
+
if (!match) {
|
|
141
|
+
this.config.fallback?.(url);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (this.config.onLink) {
|
|
146
|
+
this.config.onLink(match);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const layout = this.config.getModal
|
|
151
|
+
? this.config.getModal(match)
|
|
152
|
+
: this.modalLayoutBuilder.build(match);
|
|
153
|
+
|
|
154
|
+
if (layout) {
|
|
155
|
+
this.showModal(layout);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private isReady(): boolean {
|
|
160
|
+
if (!this.rootReady) return false;
|
|
161
|
+
if (this.userReadyOverride !== null) return this.userReadyOverride;
|
|
162
|
+
if (this.config?.isReady) return this.config.isReady();
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private refreshReady(): void {
|
|
167
|
+
this.deferredQueue.setReady(this.isReady());
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { ModalLayoutBuilder } from './ModalLayoutBuilder';
|
|
2
|
+
|
|
3
|
+
describe('ModalLayoutBuilder', () => {
|
|
4
|
+
let uut: ModalLayoutBuilder;
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
uut = new ModalLayoutBuilder();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('wraps a single-segment match in a stack', () => {
|
|
11
|
+
const layout = uut.build({
|
|
12
|
+
url: 'myapp://home',
|
|
13
|
+
path: [{ screen: 'Home', params: {} }],
|
|
14
|
+
queryParams: {},
|
|
15
|
+
});
|
|
16
|
+
expect(layout).toEqual({
|
|
17
|
+
stack: {
|
|
18
|
+
children: [
|
|
19
|
+
{
|
|
20
|
+
component: { name: 'Home', passProps: {} },
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('builds a push chain for multi-segment matches', () => {
|
|
28
|
+
const layout = uut.build({
|
|
29
|
+
url: 'myapp://settings/notifications',
|
|
30
|
+
path: [
|
|
31
|
+
{ screen: 'Settings', params: {} },
|
|
32
|
+
{ screen: 'Notifications', params: {} },
|
|
33
|
+
],
|
|
34
|
+
queryParams: {},
|
|
35
|
+
});
|
|
36
|
+
expect(layout.stack?.children).toEqual([
|
|
37
|
+
{ component: { name: 'Settings', passProps: {} } },
|
|
38
|
+
{ component: { name: 'Notifications', passProps: {} } },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('passes path params as passProps', () => {
|
|
43
|
+
const layout = uut.build({
|
|
44
|
+
url: 'myapp://user/42',
|
|
45
|
+
path: [{ screen: 'Profile', params: { id: '42' } }],
|
|
46
|
+
queryParams: {},
|
|
47
|
+
});
|
|
48
|
+
expect(layout.stack?.children?.[0].component?.passProps).toEqual({ id: '42' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('merges query params into every segment, with path params taking precedence', () => {
|
|
52
|
+
const layout = uut.build({
|
|
53
|
+
url: 'myapp://user/42?id=99&source=push',
|
|
54
|
+
path: [{ screen: 'Profile', params: { id: '42' } }],
|
|
55
|
+
queryParams: { id: '99', source: 'push' },
|
|
56
|
+
});
|
|
57
|
+
expect(layout.stack?.children?.[0].component?.passProps).toEqual({
|
|
58
|
+
id: '42',
|
|
59
|
+
source: 'push',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('applies query params to every segment of a nested match', () => {
|
|
64
|
+
const layout = uut.build({
|
|
65
|
+
url: 'myapp://settings/notifications?source=push',
|
|
66
|
+
path: [
|
|
67
|
+
{ screen: 'Settings', params: {} },
|
|
68
|
+
{ screen: 'Notifications', params: {} },
|
|
69
|
+
],
|
|
70
|
+
queryParams: { source: 'push' },
|
|
71
|
+
});
|
|
72
|
+
expect(layout.stack?.children?.[0].component?.passProps).toEqual({ source: 'push' });
|
|
73
|
+
expect(layout.stack?.children?.[1].component?.passProps).toEqual({ source: 'push' });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('filters the React-reserved "ref" query param from passProps', () => {
|
|
77
|
+
const layout = uut.build({
|
|
78
|
+
url: 'myapp://user/42?ref=notification&utm=push',
|
|
79
|
+
path: [{ screen: 'Profile', params: { id: '42' } }],
|
|
80
|
+
queryParams: { ref: 'notification', utm: 'push' },
|
|
81
|
+
});
|
|
82
|
+
expect(layout.stack?.children?.[0].component?.passProps).toEqual({
|
|
83
|
+
id: '42',
|
|
84
|
+
utm: 'push',
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('filters the React-reserved "key" query param from passProps', () => {
|
|
89
|
+
const layout = uut.build({
|
|
90
|
+
url: 'myapp://home?key=abc',
|
|
91
|
+
path: [{ screen: 'Home', params: {} }],
|
|
92
|
+
queryParams: { key: 'abc' },
|
|
93
|
+
});
|
|
94
|
+
expect(layout.stack?.children?.[0].component?.passProps).toEqual({});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('filters reserved keys when they appear as path params too', () => {
|
|
98
|
+
const layout = uut.build({
|
|
99
|
+
url: 'myapp://r/abc',
|
|
100
|
+
path: [{ screen: 'Home', params: { ref: 'abc' } }],
|
|
101
|
+
queryParams: {},
|
|
102
|
+
});
|
|
103
|
+
expect(layout.stack?.children?.[0].component?.passProps).toEqual({});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Layout } from '../interfaces/Layout';
|
|
2
|
+
import { RouteMatch, RouteMatchSegment } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Props that React treats specially and must never be forwarded as
|
|
6
|
+
* regular component props. If a URL contains query parameters or path
|
|
7
|
+
* parameters using these names, they are dropped from `passProps`
|
|
8
|
+
* (with a dev-mode warning) to avoid crashing the screen.
|
|
9
|
+
*/
|
|
10
|
+
const RESERVED_PROP_NAMES = new Set(['ref', 'key']);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Builds the default modal layout for a matched deep link.
|
|
14
|
+
*
|
|
15
|
+
* Behavior:
|
|
16
|
+
* - Wraps the matched chain in a `stack`. Even a single-component match
|
|
17
|
+
* gets wrapped so the screen has a topBar where the user can mount a
|
|
18
|
+
* close button via `topBar.leftButtons`.
|
|
19
|
+
* - Each segment becomes a `component` child of the stack, in match order.
|
|
20
|
+
* The first segment is the root of the stack; subsequent segments are
|
|
21
|
+
* pushed on top.
|
|
22
|
+
* - Path params for each segment are merged with query params and passed
|
|
23
|
+
* as `passProps` to that segment's component. Path params win on key
|
|
24
|
+
* collision. React-reserved keys (`ref`, `key`) are filtered out.
|
|
25
|
+
*/
|
|
26
|
+
export class ModalLayoutBuilder {
|
|
27
|
+
public build(match: RouteMatch): Layout {
|
|
28
|
+
return {
|
|
29
|
+
stack: {
|
|
30
|
+
children: match.path.map((segment) => ({
|
|
31
|
+
component: {
|
|
32
|
+
name: segment.screen,
|
|
33
|
+
passProps: this.mergeProps(segment, match.queryParams),
|
|
34
|
+
},
|
|
35
|
+
})),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private mergeProps(
|
|
41
|
+
segment: RouteMatchSegment,
|
|
42
|
+
queryParams: Record<string, string>
|
|
43
|
+
): Record<string, string> {
|
|
44
|
+
const merged: Record<string, string> = { ...queryParams, ...segment.params };
|
|
45
|
+
const safe: Record<string, string> = {};
|
|
46
|
+
for (const key of Object.keys(merged)) {
|
|
47
|
+
if (RESERVED_PROP_NAMES.has(key)) {
|
|
48
|
+
if (__DEV__) {
|
|
49
|
+
console.warn(
|
|
50
|
+
`[RNN linking] Dropping reserved prop "${key}" from passProps for screen "${segment.screen}". ` +
|
|
51
|
+
`Rename the URL parameter to avoid conflicting with React-reserved names.`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
safe[key] = merged[key];
|
|
57
|
+
}
|
|
58
|
+
return safe;
|
|
59
|
+
}
|
|
60
|
+
}
|