react-native-navigation 8.8.5 → 8.8.6-snapshot.2571
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/NavigationActivity.java +14 -1
- 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/options/NavigationBarOptions.java +19 -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/utils/SystemUiUtils.kt +63 -9
- package/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabPresenter.java +28 -0
- package/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsController.java +77 -6
- package/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsPresenter.kt +1 -0
- package/android/src/main/java/com/reactnativenavigation/viewcontrollers/component/ComponentViewController.java +2 -5
- package/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/Presenter.java +33 -13
- 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/test/java/com/reactnativenavigation/presentation/PresenterTest.java +14 -0
- package/android/src/test/java/com/reactnativenavigation/utils/SystemUiUtilsTest.kt +64 -1
- 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 +90 -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 +92 -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,128 @@
|
|
|
1
|
+
import { RouteMatcher } from './RouteMatcher';
|
|
2
|
+
import { ScreensConfig } from './types';
|
|
3
|
+
|
|
4
|
+
describe('RouteMatcher', () => {
|
|
5
|
+
let uut: RouteMatcher;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
uut = new RouteMatcher();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
function configure(screens: ScreensConfig) {
|
|
12
|
+
uut.setRouteTree(uut.buildRouteTree(screens));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
it('matches a flat route', () => {
|
|
16
|
+
configure({ Home: 'home' });
|
|
17
|
+
expect(uut.match('home', 'myapp://home', {})).toEqual({
|
|
18
|
+
url: 'myapp://home',
|
|
19
|
+
path: [{ screen: 'Home', params: {} }],
|
|
20
|
+
queryParams: {},
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('extracts a single path parameter', () => {
|
|
25
|
+
configure({ Profile: 'user/:id' });
|
|
26
|
+
expect(uut.match('user/42', 'myapp://user/42', {})).toEqual({
|
|
27
|
+
url: 'myapp://user/42',
|
|
28
|
+
path: [{ screen: 'Profile', params: { id: '42' } }],
|
|
29
|
+
queryParams: {},
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('extracts multiple path parameters', () => {
|
|
34
|
+
configure({ Post: 'user/:userId/post/:postId' });
|
|
35
|
+
const result = uut.match('user/5/post/99', 'myapp://user/5/post/99', {});
|
|
36
|
+
expect(result?.path[0].params).toEqual({ userId: '5', postId: '99' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns null when no route matches', () => {
|
|
40
|
+
configure({ Home: 'home' });
|
|
41
|
+
expect(uut.match('unknown', 'myapp://unknown', {})).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns null when path is longer than any pattern', () => {
|
|
45
|
+
configure({ Home: 'home' });
|
|
46
|
+
expect(uut.match('home/extra', 'myapp://home/extra', {})).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('matches the first registered route on ambiguity', () => {
|
|
50
|
+
configure({
|
|
51
|
+
Profile: 'user/:id',
|
|
52
|
+
User: 'user/:userId',
|
|
53
|
+
});
|
|
54
|
+
const result = uut.match('user/42', 'myapp://user/42', {});
|
|
55
|
+
expect(result?.path[0].screen).toBe('Profile');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('matches a nested route as a chain', () => {
|
|
59
|
+
configure({
|
|
60
|
+
Settings: {
|
|
61
|
+
path: 'settings',
|
|
62
|
+
screens: { Notifications: 'notifications' },
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
expect(uut.match('settings/notifications', 'myapp://settings/notifications', {})).toEqual({
|
|
66
|
+
url: 'myapp://settings/notifications',
|
|
67
|
+
path: [
|
|
68
|
+
{ screen: 'Settings', params: {} },
|
|
69
|
+
{ screen: 'Notifications', params: {} },
|
|
70
|
+
],
|
|
71
|
+
queryParams: {},
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('matches a parent-only nested route', () => {
|
|
76
|
+
configure({
|
|
77
|
+
Settings: {
|
|
78
|
+
path: 'settings',
|
|
79
|
+
screens: { Notifications: 'notifications' },
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
expect(uut.match('settings', 'myapp://settings', {})).toEqual({
|
|
83
|
+
url: 'myapp://settings',
|
|
84
|
+
path: [{ screen: 'Settings', params: {} }],
|
|
85
|
+
queryParams: {},
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('supports grouping nodes that consume no segments', () => {
|
|
90
|
+
configure({
|
|
91
|
+
Main: {
|
|
92
|
+
screens: {
|
|
93
|
+
Feed: 'feed',
|
|
94
|
+
Search: 'search',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
const result = uut.match('feed', 'myapp://feed', {});
|
|
99
|
+
expect(result?.path).toEqual([
|
|
100
|
+
{ screen: 'Main', params: {} },
|
|
101
|
+
{ screen: 'Feed', params: {} },
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('passes query params through to the match result', () => {
|
|
106
|
+
configure({ Home: 'home' });
|
|
107
|
+
const result = uut.match('home', 'myapp://home?ref=push', { ref: 'push' });
|
|
108
|
+
expect(result?.queryParams).toEqual({ ref: 'push' });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns null when no route tree has been configured', () => {
|
|
112
|
+
expect(uut.match('home', 'myapp://home', {})).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('matches nested params at multiple levels', () => {
|
|
116
|
+
configure({
|
|
117
|
+
Org: {
|
|
118
|
+
path: 'org/:orgId',
|
|
119
|
+
screens: { Project: 'project/:projectId' },
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const result = uut.match('org/1/project/2', 'myapp://org/1/project/2', {});
|
|
123
|
+
expect(result?.path).toEqual([
|
|
124
|
+
{ screen: 'Org', params: { orgId: '1' } },
|
|
125
|
+
{ screen: 'Project', params: { projectId: '2' } },
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ScreensConfig,
|
|
3
|
+
ScreenConfig,
|
|
4
|
+
RouteNode,
|
|
5
|
+
RouteMatch,
|
|
6
|
+
RouteMatchSegment,
|
|
7
|
+
} from './types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Compiles a `screens` config into a tree of `RouteNode`s and resolves
|
|
11
|
+
* parsed paths to a matched `RouteMatch`.
|
|
12
|
+
*
|
|
13
|
+
* Matching is depth-first, in insertion order. The first node whose pattern
|
|
14
|
+
* (and nested children) consume the entire path wins.
|
|
15
|
+
*/
|
|
16
|
+
export class RouteMatcher {
|
|
17
|
+
private routeTree: RouteNode[] | null = null;
|
|
18
|
+
|
|
19
|
+
public buildRouteTree(screens: ScreensConfig): RouteNode[] {
|
|
20
|
+
const nodes: RouteNode[] = [];
|
|
21
|
+
for (const [screenName, config] of Object.entries(screens)) {
|
|
22
|
+
nodes.push(this.buildNode(screenName, config));
|
|
23
|
+
}
|
|
24
|
+
return nodes;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public setRouteTree(tree: RouteNode[]): void {
|
|
28
|
+
this.routeTree = tree;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Match a parsed path (e.g. `"user/42"`) against the configured tree.
|
|
33
|
+
* Returns the matched chain or `null` when no route matches.
|
|
34
|
+
*/
|
|
35
|
+
public match(
|
|
36
|
+
path: string,
|
|
37
|
+
url: string,
|
|
38
|
+
queryParams: Record<string, string>
|
|
39
|
+
): RouteMatch | null {
|
|
40
|
+
if (!this.routeTree) return null;
|
|
41
|
+
|
|
42
|
+
const pathSegments = path.split('/').filter((s) => s.length > 0);
|
|
43
|
+
const result = this.matchRecursive(pathSegments, 0, this.routeTree);
|
|
44
|
+
if (!result) return null;
|
|
45
|
+
|
|
46
|
+
return { url, path: result, queryParams };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private buildNode(screenName: string, config: ScreenConfig): RouteNode {
|
|
50
|
+
if (typeof config === 'string') {
|
|
51
|
+
return {
|
|
52
|
+
screen: screenName,
|
|
53
|
+
pattern: config,
|
|
54
|
+
segments: this.splitPattern(config),
|
|
55
|
+
children: [],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const children = config.screens ? this.buildRouteTree(config.screens) : [];
|
|
60
|
+
return {
|
|
61
|
+
screen: screenName,
|
|
62
|
+
pattern: config.path ?? null,
|
|
63
|
+
segments: config.path ? this.splitPattern(config.path) : [],
|
|
64
|
+
children,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private splitPattern(pattern: string): string[] {
|
|
69
|
+
return pattern.split('/').filter((s) => s.length > 0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private matchRecursive(
|
|
73
|
+
pathSegments: string[],
|
|
74
|
+
offset: number,
|
|
75
|
+
nodes: RouteNode[]
|
|
76
|
+
): RouteMatchSegment[] | null {
|
|
77
|
+
for (const node of nodes) {
|
|
78
|
+
const result = this.tryMatchNode(pathSegments, offset, node);
|
|
79
|
+
if (result) return result;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private tryMatchNode(
|
|
85
|
+
pathSegments: string[],
|
|
86
|
+
offset: number,
|
|
87
|
+
node: RouteNode
|
|
88
|
+
): RouteMatchSegment[] | null {
|
|
89
|
+
if (node.pattern === null && node.segments.length === 0) {
|
|
90
|
+
if (node.children.length === 0) return null;
|
|
91
|
+
const childMatch = this.matchRecursive(pathSegments, offset, node.children);
|
|
92
|
+
if (!childMatch) return null;
|
|
93
|
+
return [{ screen: node.screen, params: {} }, ...childMatch];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const patternSegments = node.segments;
|
|
97
|
+
if (offset + patternSegments.length > pathSegments.length) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const params: Record<string, string> = {};
|
|
102
|
+
for (let i = 0; i < patternSegments.length; i++) {
|
|
103
|
+
const pattern = patternSegments[i];
|
|
104
|
+
const actual = pathSegments[offset + i];
|
|
105
|
+
if (pattern.startsWith(':')) {
|
|
106
|
+
params[pattern.slice(1)] = actual;
|
|
107
|
+
} else if (pattern !== actual) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const newOffset = offset + patternSegments.length;
|
|
113
|
+
const segment: RouteMatchSegment = { screen: node.screen, params };
|
|
114
|
+
|
|
115
|
+
if (newOffset === pathSegments.length) {
|
|
116
|
+
return [segment];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (node.children.length > 0) {
|
|
120
|
+
const childMatch = this.matchRecursive(pathSegments, newOffset, node.children);
|
|
121
|
+
if (childMatch) return [segment, ...childMatch];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { URLParser } from './URLParser';
|
|
2
|
+
|
|
3
|
+
describe('URLParser', () => {
|
|
4
|
+
let uut: URLParser;
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
uut = new URLParser();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('stripPrefix', () => {
|
|
11
|
+
it('returns the remainder after the matching prefix', () => {
|
|
12
|
+
expect(uut.stripPrefix('myapp://home', ['myapp://'])).toBe('home');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns null when no prefix matches', () => {
|
|
16
|
+
expect(uut.stripPrefix('other://home', ['myapp://'])).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('chooses the longest matching prefix', () => {
|
|
20
|
+
const result = uut.stripPrefix('https://myapp.com/v2/home', [
|
|
21
|
+
'https://myapp.com',
|
|
22
|
+
'https://myapp.com/v2',
|
|
23
|
+
]);
|
|
24
|
+
expect(result).toBe('/home');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('parse', () => {
|
|
29
|
+
const prefixes = ['myapp://', 'https://myapp.com'];
|
|
30
|
+
|
|
31
|
+
it('returns null when no prefix matches', () => {
|
|
32
|
+
expect(uut.parse('other://home', prefixes)).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('parses a simple path', () => {
|
|
36
|
+
expect(uut.parse('myapp://home', prefixes)).toEqual({
|
|
37
|
+
path: 'home',
|
|
38
|
+
queryParams: {},
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('trims leading and trailing slashes', () => {
|
|
43
|
+
expect(uut.parse('myapp:///home/', prefixes)).toEqual({
|
|
44
|
+
path: 'home',
|
|
45
|
+
queryParams: {},
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('parses query parameters', () => {
|
|
50
|
+
expect(uut.parse('myapp://search?q=hello&page=2', prefixes)).toEqual({
|
|
51
|
+
path: 'search',
|
|
52
|
+
queryParams: { q: 'hello', page: '2' },
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('decodes URL-encoded path segments', () => {
|
|
57
|
+
expect(uut.parse('myapp://hello%20world', prefixes)).toEqual({
|
|
58
|
+
path: 'hello world',
|
|
59
|
+
queryParams: {},
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('decodes URL-encoded query parameter keys and values', () => {
|
|
64
|
+
expect(uut.parse('myapp://search?q=hello%20world&a%20b=c', prefixes)).toEqual({
|
|
65
|
+
path: 'search',
|
|
66
|
+
queryParams: { q: 'hello world', 'a b': 'c' },
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('strips fragments before parsing the query', () => {
|
|
71
|
+
expect(uut.parse('myapp://home?x=1#section', prefixes)).toEqual({
|
|
72
|
+
path: 'home',
|
|
73
|
+
queryParams: { x: '1' },
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('strips fragments when there is no query string', () => {
|
|
78
|
+
expect(uut.parse('myapp://home#section', prefixes)).toEqual({
|
|
79
|
+
path: 'home',
|
|
80
|
+
queryParams: {},
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('treats query keys without values as empty strings', () => {
|
|
85
|
+
expect(uut.parse('myapp://home?flag', prefixes)).toEqual({
|
|
86
|
+
path: 'home',
|
|
87
|
+
queryParams: { flag: '' },
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('handles malformed encoding without throwing', () => {
|
|
92
|
+
expect(uut.parse('myapp://%E0%A4%A', prefixes)).toEqual({
|
|
93
|
+
path: '%E0%A4%A',
|
|
94
|
+
queryParams: {},
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('keeps "=" inside values intact', () => {
|
|
99
|
+
expect(uut.parse('myapp://search?token=a=b=c', prefixes)).toEqual({
|
|
100
|
+
path: 'search',
|
|
101
|
+
queryParams: { token: 'a=b=c' },
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ParsedURL } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parses a URL string against a list of configured prefixes.
|
|
5
|
+
*
|
|
6
|
+
* Responsibilities:
|
|
7
|
+
* - Strip the longest matching prefix (or report no match).
|
|
8
|
+
* - Drop any `#fragment` portion.
|
|
9
|
+
* - Decode each path segment so literal patterns compare against decoded
|
|
10
|
+
* text (e.g. `hello%20world` matches a `hello world` pattern segment).
|
|
11
|
+
* - Parse the query string into a plain key/value map.
|
|
12
|
+
*/
|
|
13
|
+
export class URLParser {
|
|
14
|
+
public stripPrefix(url: string, prefixes: string[]): string | null {
|
|
15
|
+
const sorted = [...prefixes].sort((a, b) => b.length - a.length);
|
|
16
|
+
for (const prefix of sorted) {
|
|
17
|
+
if (url.startsWith(prefix)) {
|
|
18
|
+
return url.slice(prefix.length);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public parse(url: string, prefixes: string[]): ParsedURL | null {
|
|
25
|
+
const stripped = this.stripPrefix(url, prefixes);
|
|
26
|
+
if (stripped === null) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const withoutFragment = stripped.split('#', 1)[0];
|
|
31
|
+
const [pathPart, queryPart] = withoutFragment.split('?', 2);
|
|
32
|
+
|
|
33
|
+
const path = pathPart
|
|
34
|
+
.split('/')
|
|
35
|
+
.filter((s) => s.length > 0)
|
|
36
|
+
.map((segment) => safeDecode(segment))
|
|
37
|
+
.join('/');
|
|
38
|
+
|
|
39
|
+
const queryParams: Record<string, string> = {};
|
|
40
|
+
if (queryPart) {
|
|
41
|
+
for (const pair of queryPart.split('&')) {
|
|
42
|
+
if (!pair) continue;
|
|
43
|
+
const eqIndex = pair.indexOf('=');
|
|
44
|
+
const rawKey = eqIndex === -1 ? pair : pair.slice(0, eqIndex);
|
|
45
|
+
const rawValue = eqIndex === -1 ? '' : pair.slice(eqIndex + 1);
|
|
46
|
+
const key = safeDecode(rawKey);
|
|
47
|
+
if (!key) continue;
|
|
48
|
+
queryParams[key] = safeDecode(rawValue);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { path, queryParams };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function safeDecode(value: string): string {
|
|
57
|
+
try {
|
|
58
|
+
return decodeURIComponent(value);
|
|
59
|
+
} catch {
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Layout } from '../interfaces/Layout';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single matched segment of a deep link route.
|
|
5
|
+
*/
|
|
6
|
+
export interface RouteMatchSegment {
|
|
7
|
+
/** Name of the registered RNN component this segment maps to. */
|
|
8
|
+
screen: string;
|
|
9
|
+
/** Path parameters extracted from this segment's pattern (e.g. `:id`). */
|
|
10
|
+
params: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Result of matching a URL against the configured `screens` tree.
|
|
15
|
+
*/
|
|
16
|
+
export interface RouteMatch {
|
|
17
|
+
/** Original URL that was matched. */
|
|
18
|
+
url: string;
|
|
19
|
+
/**
|
|
20
|
+
* Ordered chain of matched screens, from outermost to innermost.
|
|
21
|
+
* For a nested route like `settings/notifications`, this contains
|
|
22
|
+
* `[{ screen: 'Settings' }, { screen: 'Notifications' }]`.
|
|
23
|
+
*/
|
|
24
|
+
path: RouteMatchSegment[];
|
|
25
|
+
/** Query string parameters from the URL. */
|
|
26
|
+
queryParams: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Internal representation of a parsed URL after prefix stripping.
|
|
31
|
+
*/
|
|
32
|
+
export interface ParsedURL {
|
|
33
|
+
/** Decoded path with leading/trailing slashes removed. */
|
|
34
|
+
path: string;
|
|
35
|
+
/** Query string parameters. */
|
|
36
|
+
queryParams: Record<string, string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Internal compiled representation of a single screen entry in the route tree.
|
|
41
|
+
*/
|
|
42
|
+
export interface RouteNode {
|
|
43
|
+
/** Screen name (the key from `screens`). */
|
|
44
|
+
screen: string;
|
|
45
|
+
/** Path pattern as written by the user, or `null` for grouping nodes. */
|
|
46
|
+
pattern: string | null;
|
|
47
|
+
/** Pattern split into segments, ready for matching. */
|
|
48
|
+
segments: string[];
|
|
49
|
+
/** Nested children. */
|
|
50
|
+
children: RouteNode[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Per-screen configuration. Either a path pattern string, or an object with
|
|
55
|
+
* an optional path and nested screens.
|
|
56
|
+
*
|
|
57
|
+
* Examples:
|
|
58
|
+
* `'home'` - leaf route
|
|
59
|
+
* `'user/:id'` - leaf with path parameter
|
|
60
|
+
* `{ path: 'settings', screens: {...} }` - nested route
|
|
61
|
+
* `{ screens: {...} }` - grouping node (no path)
|
|
62
|
+
*/
|
|
63
|
+
export type ScreenConfig =
|
|
64
|
+
| string
|
|
65
|
+
| {
|
|
66
|
+
path?: string;
|
|
67
|
+
screens?: ScreensConfig;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export interface ScreensConfig {
|
|
71
|
+
[screenName: string]: ScreenConfig;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Configuration passed to `Navigation.setLinking`.
|
|
76
|
+
*
|
|
77
|
+
* When a deep link is received, RNN parses it, matches it against the
|
|
78
|
+
* `screens` tree, and by default presents the matched chain as a modal
|
|
79
|
+
* (wrapped in a stack so a topBar is available for a close button).
|
|
80
|
+
*
|
|
81
|
+
* To customize the modal layout, provide `getModal`. To bypass the modal
|
|
82
|
+
* behavior entirely (e.g. push onto an existing stack), provide `onLink`.
|
|
83
|
+
*/
|
|
84
|
+
export interface LinkingConfig {
|
|
85
|
+
/** URL prefixes your app handles (custom schemes and universal-link hosts). */
|
|
86
|
+
prefixes: string[];
|
|
87
|
+
/** Screen-to-path mapping. */
|
|
88
|
+
config: {
|
|
89
|
+
screens: ScreensConfig;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Customize the modal layout for a matched route. Return `undefined` to
|
|
93
|
+
* skip presenting a modal for this match (useful for conditional skipping).
|
|
94
|
+
* When omitted, the default builder wraps the matched chain in a stack.
|
|
95
|
+
*/
|
|
96
|
+
getModal?: (match: RouteMatch) => Layout | undefined;
|
|
97
|
+
/**
|
|
98
|
+
* Full escape hatch. When provided, RNN does not present a modal; the
|
|
99
|
+
* handler is responsible for executing whatever navigation commands it
|
|
100
|
+
* wants (push, setRoot, dismissAllModals + showModal, etc.).
|
|
101
|
+
* When set, `getModal` is ignored.
|
|
102
|
+
*/
|
|
103
|
+
onLink?: (match: RouteMatch) => void;
|
|
104
|
+
/**
|
|
105
|
+
* Called when a received URL does not match any configured route or has
|
|
106
|
+
* no matching prefix. Useful for logging or routing to a "not found" flow.
|
|
107
|
+
*/
|
|
108
|
+
fallback?: (url: string) => void;
|
|
109
|
+
/**
|
|
110
|
+
* Predicate evaluated before each link is processed. When it returns
|
|
111
|
+
* `false`, the link is queued and replayed once `setLinkingReady(true)`
|
|
112
|
+
* is called. Use this to defer links until e.g. authentication completes.
|
|
113
|
+
*/
|
|
114
|
+
isReady?: () => boolean;
|
|
115
|
+
}
|