@react-navigation/lynx 0.2.0 → 0.3.0-canary-20260907-96fbfef4
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/package.json +5 -4
- package/src/NavigationContainer.tsx +21 -24
- package/src/__tests__/extractPathFromURL.test.ts +505 -0
- package/src/__tests__/linking.test.ts +163 -0
- package/src/extractPathFromURL.ts +42 -0
- package/src/index.tsx +10 -0
- package/src/linking.ts +101 -0
- package/src/useLinking.ts +180 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@react-navigation/lynx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-canary-20260907-96fbfef4",
|
|
4
4
|
"description": "Lynx integration for React Navigation",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"src"
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"escape-string-regexp": "^5.0.0",
|
|
35
36
|
"@react-navigation/core": "^8.0.0-alpha.33"
|
|
36
37
|
},
|
|
37
38
|
"peerDependencies": {
|
|
@@ -39,9 +40,9 @@
|
|
|
39
40
|
"lynx-screens": "*"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
42
|
-
"@lynx-js/react": "^0.
|
|
43
|
-
"@lynx-js/testing-environment": "^0.3.
|
|
44
|
-
"@lynx-js/types": "^4.1
|
|
43
|
+
"@lynx-js/react": "^0.126.0",
|
|
44
|
+
"@lynx-js/testing-environment": "^0.3.4",
|
|
45
|
+
"@lynx-js/types": "^4.2.1",
|
|
45
46
|
"@types/react": "~19.2.17",
|
|
46
47
|
"jsdom": "^26.0.0",
|
|
47
48
|
"typescript": "^6.0.3",
|
|
@@ -10,42 +10,30 @@ import {
|
|
|
10
10
|
import * as React from 'react';
|
|
11
11
|
|
|
12
12
|
import { LightTheme } from './theming/LightTheme';
|
|
13
|
+
import { type LinkingOptions, useLinking } from './useLinking';
|
|
13
14
|
|
|
14
15
|
export type NavigationContainerLynxProps<
|
|
15
16
|
ParamList extends {} = ParamListBase,
|
|
16
17
|
> = NavigationContainerProps & {
|
|
17
|
-
/**
|
|
18
|
-
* Theme handed to `useTheme` and to any navigator that reads colors.
|
|
19
|
-
*/
|
|
20
18
|
theme?: Theme | undefined;
|
|
21
|
-
/**
|
|
22
|
-
* Rendered while persisted state is being restored.
|
|
23
|
-
*/
|
|
19
|
+
/** Rendered while persisted state is being restored. */
|
|
24
20
|
fallback?: React.ReactNode | undefined;
|
|
21
|
+
/** Maps URLs handed over by the host onto navigation state. */
|
|
22
|
+
linking?: LinkingOptions<ParamList> | undefined;
|
|
25
23
|
ref?: React.Ref<NavigationContainerRef<ParamList>> | undefined;
|
|
26
24
|
};
|
|
27
25
|
|
|
28
26
|
/**
|
|
29
27
|
* The Lynx counterpart of `@react-navigation/native`'s `NavigationContainer`.
|
|
30
28
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* state persistence - is wired here rather than in each navigator.
|
|
34
|
-
*
|
|
35
|
-
* Not yet ported from React Native:
|
|
36
|
-
*
|
|
37
|
-
* - deep linking (`linking`), which needs a Lynx URL source
|
|
38
|
-
* - state persistence, which needs a Lynx storage binding
|
|
39
|
-
* - `useDocumentTitle`, which is a browser concern and has no Lynx meaning
|
|
40
|
-
*
|
|
41
|
-
* The hardware back button is deliberately absent: on Lynx it is handled by
|
|
42
|
-
* the native stack per screen, through `preventNativeDismiss` and the dismiss
|
|
43
|
-
* callbacks, so a container-level handler would fight with it.
|
|
29
|
+
* No state persistence yet, and no back-button handler: the native stack owns
|
|
30
|
+
* dismissal per screen, so a container-level one would fight with it.
|
|
44
31
|
*/
|
|
45
32
|
export function NavigationContainer<ParamList extends {} = ParamListBase>({
|
|
46
33
|
theme = LightTheme,
|
|
47
34
|
fallback = null,
|
|
48
35
|
onStateChange,
|
|
36
|
+
linking,
|
|
49
37
|
ref,
|
|
50
38
|
...rest
|
|
51
39
|
}: NavigationContainerLynxProps<ParamList>) {
|
|
@@ -57,23 +45,32 @@ export function NavigationContainer<ParamList extends {} = ParamListBase>({
|
|
|
57
45
|
() => refContainer.current as NavigationContainerRef<ParamList>
|
|
58
46
|
);
|
|
59
47
|
|
|
48
|
+
const { getInitialState } = useLinking(refContainer, linking);
|
|
49
|
+
|
|
50
|
+
// Read once: later URLs go through the subscription instead.
|
|
51
|
+
const [linkingInitialState] = React.useState(() =>
|
|
52
|
+
rest.initialState != null ? undefined : getInitialState()
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const { children: _children, ...restWithoutChildren } = rest;
|
|
56
|
+
|
|
60
57
|
const handleStateChange = (state: Readonly<NavigationState> | undefined) => {
|
|
61
58
|
onStateChange?.(state);
|
|
62
59
|
};
|
|
63
60
|
|
|
64
|
-
// Kept for parity with React Native, where this renders while persisted
|
|
65
|
-
// state is being restored. With no persistence yet there is nothing to wait
|
|
66
|
-
// for, so it only shows if a caller passes `fallback` and no children.
|
|
67
61
|
if (rest.children == null) {
|
|
68
62
|
return <ThemeProvider value={theme}>{fallback}</ThemeProvider>;
|
|
69
63
|
}
|
|
70
64
|
|
|
71
65
|
return (
|
|
72
66
|
<BaseNavigationContainer
|
|
73
|
-
{...
|
|
67
|
+
{...restWithoutChildren}
|
|
68
|
+
initialState={rest.initialState ?? linkingInitialState}
|
|
74
69
|
theme={theme}
|
|
75
70
|
onStateChange={handleStateChange}
|
|
76
71
|
ref={refContainer}
|
|
77
|
-
|
|
72
|
+
>
|
|
73
|
+
{rest.children}
|
|
74
|
+
</BaseNavigationContainer>
|
|
78
75
|
);
|
|
79
76
|
}
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { extractPathFromURL } from '../extractPathFromURL';
|
|
4
|
+
|
|
5
|
+
test('extracts path from URL with protocol', () => {
|
|
6
|
+
expect(extractPathFromURL(['scheme://'], 'scheme://some/path')).toBe(
|
|
7
|
+
'/some/path'
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
expect(extractPathFromURL(['scheme://'], 'scheme:some/path')).toBe(
|
|
11
|
+
'/some/path'
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
expect(extractPathFromURL(['scheme://'], 'scheme:///some/path')).toBe(
|
|
15
|
+
'/some/path'
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
expect(extractPathFromURL(['scheme:///'], 'scheme:some/path')).toBe(
|
|
19
|
+
'/some/path'
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(extractPathFromURL(['scheme:'], 'scheme:some/path')).toBe(
|
|
23
|
+
'/some/path'
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
expect(extractPathFromURL(['scheme:'], 'scheme://some/path')).toBe(
|
|
27
|
+
'/some/path'
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(extractPathFromURL(['scheme:'], 'scheme:///some/path')).toBe(
|
|
31
|
+
'/some/path'
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('extracts path from URL with protocol and host', () => {
|
|
36
|
+
expect(
|
|
37
|
+
extractPathFromURL(
|
|
38
|
+
['scheme://example.com'],
|
|
39
|
+
'scheme://example.com/some/path'
|
|
40
|
+
)
|
|
41
|
+
).toBe('/some/path');
|
|
42
|
+
|
|
43
|
+
expect(
|
|
44
|
+
extractPathFromURL(['scheme://example.com'], 'scheme:example.com/some/path')
|
|
45
|
+
).toBe('/some/path');
|
|
46
|
+
|
|
47
|
+
expect(
|
|
48
|
+
extractPathFromURL(
|
|
49
|
+
['scheme://example.com'],
|
|
50
|
+
'scheme:///example.com/some/path'
|
|
51
|
+
)
|
|
52
|
+
).toBe('/some/path');
|
|
53
|
+
|
|
54
|
+
expect(
|
|
55
|
+
extractPathFromURL(
|
|
56
|
+
['scheme:///example.com'],
|
|
57
|
+
'scheme:example.com/some/path'
|
|
58
|
+
)
|
|
59
|
+
).toBe('/some/path');
|
|
60
|
+
|
|
61
|
+
expect(
|
|
62
|
+
extractPathFromURL(['scheme:example.com'], 'scheme:example.com/some/path')
|
|
63
|
+
).toBe('/some/path');
|
|
64
|
+
|
|
65
|
+
expect(
|
|
66
|
+
extractPathFromURL(['scheme:example.com'], 'scheme://example.com/some/path')
|
|
67
|
+
).toBe('/some/path');
|
|
68
|
+
|
|
69
|
+
expect(
|
|
70
|
+
extractPathFromURL(
|
|
71
|
+
['scheme:example.com'],
|
|
72
|
+
'scheme:///example.com/some/path'
|
|
73
|
+
)
|
|
74
|
+
).toBe('/some/path');
|
|
75
|
+
|
|
76
|
+
expect(
|
|
77
|
+
extractPathFromURL(['scheme://example.com'], 'scheme://example.com/')
|
|
78
|
+
).toBe('/');
|
|
79
|
+
|
|
80
|
+
expect(
|
|
81
|
+
extractPathFromURL(['scheme://example.com'], 'scheme://example.com')
|
|
82
|
+
).toBe('/');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('extracts path from URL with protocol and host with wildcard', () => {
|
|
86
|
+
expect(
|
|
87
|
+
extractPathFromURL(
|
|
88
|
+
['scheme://*.example.com'],
|
|
89
|
+
'scheme://test.example.com/some/path'
|
|
90
|
+
)
|
|
91
|
+
).toBe('/some/path');
|
|
92
|
+
|
|
93
|
+
expect(
|
|
94
|
+
extractPathFromURL(
|
|
95
|
+
['scheme://*.example.com'],
|
|
96
|
+
'scheme:test.example.com/some/path'
|
|
97
|
+
)
|
|
98
|
+
).toBe('/some/path');
|
|
99
|
+
|
|
100
|
+
expect(
|
|
101
|
+
extractPathFromURL(
|
|
102
|
+
['scheme://*.example.com'],
|
|
103
|
+
'scheme:///test.example.com/some/path'
|
|
104
|
+
)
|
|
105
|
+
).toBe('/some/path');
|
|
106
|
+
|
|
107
|
+
expect(
|
|
108
|
+
extractPathFromURL(
|
|
109
|
+
['scheme:///*.example.com'],
|
|
110
|
+
'scheme:test.example.com/some/path'
|
|
111
|
+
)
|
|
112
|
+
).toBe('/some/path');
|
|
113
|
+
|
|
114
|
+
expect(
|
|
115
|
+
extractPathFromURL(
|
|
116
|
+
['scheme:*.example.com'],
|
|
117
|
+
'scheme:test.example.com/some/path'
|
|
118
|
+
)
|
|
119
|
+
).toBe('/some/path');
|
|
120
|
+
|
|
121
|
+
expect(
|
|
122
|
+
extractPathFromURL(
|
|
123
|
+
['scheme:*.example.com'],
|
|
124
|
+
'scheme://test.example.com/some/path'
|
|
125
|
+
)
|
|
126
|
+
).toBe('/some/path');
|
|
127
|
+
|
|
128
|
+
expect(
|
|
129
|
+
extractPathFromURL(
|
|
130
|
+
['scheme:*.example.com'],
|
|
131
|
+
'scheme:///test.example.com/some/path'
|
|
132
|
+
)
|
|
133
|
+
).toBe('/some/path');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('extracts path from URL with protocol, host and path', () => {
|
|
137
|
+
expect(
|
|
138
|
+
extractPathFromURL(
|
|
139
|
+
['scheme://example.com/test'],
|
|
140
|
+
'scheme://example.com/test/some/path'
|
|
141
|
+
)
|
|
142
|
+
).toBe('/some/path');
|
|
143
|
+
|
|
144
|
+
expect(
|
|
145
|
+
extractPathFromURL(['scheme://example.com'], 'scheme:example.com/some/path')
|
|
146
|
+
).toBe('/some/path');
|
|
147
|
+
|
|
148
|
+
expect(
|
|
149
|
+
extractPathFromURL(
|
|
150
|
+
['scheme://example.com/test'],
|
|
151
|
+
'scheme:///example.com/test/some/path'
|
|
152
|
+
)
|
|
153
|
+
).toBe('/some/path');
|
|
154
|
+
|
|
155
|
+
expect(
|
|
156
|
+
extractPathFromURL(
|
|
157
|
+
['scheme:///example.com/test'],
|
|
158
|
+
'scheme:example.com/test/some/path'
|
|
159
|
+
)
|
|
160
|
+
).toBe('/some/path');
|
|
161
|
+
|
|
162
|
+
expect(
|
|
163
|
+
extractPathFromURL(
|
|
164
|
+
['scheme:example.com/test'],
|
|
165
|
+
'scheme:example.com/test/some/path'
|
|
166
|
+
)
|
|
167
|
+
).toBe('/some/path');
|
|
168
|
+
|
|
169
|
+
expect(
|
|
170
|
+
extractPathFromURL(
|
|
171
|
+
['scheme:example.com/test'],
|
|
172
|
+
'scheme://example.com/test/some/path'
|
|
173
|
+
)
|
|
174
|
+
).toBe('/some/path');
|
|
175
|
+
|
|
176
|
+
expect(
|
|
177
|
+
extractPathFromURL(
|
|
178
|
+
['scheme:example.com/test'],
|
|
179
|
+
'scheme:///example.com/test/some/path'
|
|
180
|
+
)
|
|
181
|
+
).toBe('/some/path');
|
|
182
|
+
|
|
183
|
+
expect(
|
|
184
|
+
extractPathFromURL(
|
|
185
|
+
['scheme:example.com/test'],
|
|
186
|
+
'scheme:///example.com/test//some/path'
|
|
187
|
+
)
|
|
188
|
+
).toBe('/some/path');
|
|
189
|
+
|
|
190
|
+
expect(
|
|
191
|
+
extractPathFromURL(
|
|
192
|
+
['scheme:example.com/test'],
|
|
193
|
+
'scheme:///example.com/test/some//path'
|
|
194
|
+
)
|
|
195
|
+
).toBe('/some/path');
|
|
196
|
+
|
|
197
|
+
expect(
|
|
198
|
+
extractPathFromURL(
|
|
199
|
+
['scheme://example.com/test'],
|
|
200
|
+
'scheme://example.com/test?foo=bar'
|
|
201
|
+
)
|
|
202
|
+
).toBe('/?foo=bar');
|
|
203
|
+
|
|
204
|
+
expect(
|
|
205
|
+
extractPathFromURL(
|
|
206
|
+
['scheme://example.com/test'],
|
|
207
|
+
'scheme://example.com/test#section'
|
|
208
|
+
)
|
|
209
|
+
).toBe('/#section');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('extracts path from URL with IP address and port', () => {
|
|
213
|
+
// Test exact IP address and port matching
|
|
214
|
+
expect(
|
|
215
|
+
extractPathFromURL(
|
|
216
|
+
['http://127.0.0.1:19000'],
|
|
217
|
+
'http://127.0.0.1:19000/some/path'
|
|
218
|
+
)
|
|
219
|
+
).toBe('/some/path');
|
|
220
|
+
|
|
221
|
+
expect(
|
|
222
|
+
extractPathFromURL(
|
|
223
|
+
['https://127.0.0.1:8080'],
|
|
224
|
+
'https://127.0.0.1:8080/secure/path'
|
|
225
|
+
)
|
|
226
|
+
).toBe('/secure/path');
|
|
227
|
+
|
|
228
|
+
expect(
|
|
229
|
+
extractPathFromURL(
|
|
230
|
+
['exp://127.0.0.1:19000'],
|
|
231
|
+
'exp://127.0.0.1:19000/--/simple-stack'
|
|
232
|
+
)
|
|
233
|
+
).toBe('/--/simple-stack');
|
|
234
|
+
|
|
235
|
+
expect(
|
|
236
|
+
extractPathFromURL(
|
|
237
|
+
['rne://127.0.0.1:19000'],
|
|
238
|
+
'rne://127.0.0.1:19000/--/simple-stack'
|
|
239
|
+
)
|
|
240
|
+
).toBe('/--/simple-stack');
|
|
241
|
+
|
|
242
|
+
// Test IPv6 prefixes
|
|
243
|
+
expect(
|
|
244
|
+
extractPathFromURL(['http://[::1]:8080'], 'http://[::1]:8080/some/path')
|
|
245
|
+
).toBe('/some/path');
|
|
246
|
+
|
|
247
|
+
// Test with query parameters
|
|
248
|
+
expect(
|
|
249
|
+
extractPathFromURL(
|
|
250
|
+
['http://127.0.0.1:19000'],
|
|
251
|
+
'http://127.0.0.1:19000/path?param=value'
|
|
252
|
+
)
|
|
253
|
+
).toBe('/path?param=value');
|
|
254
|
+
|
|
255
|
+
// Test empty path
|
|
256
|
+
expect(
|
|
257
|
+
extractPathFromURL(['http://127.0.0.1:19000'], 'http://127.0.0.1:19000')
|
|
258
|
+
).toBe('/');
|
|
259
|
+
expect(
|
|
260
|
+
extractPathFromURL(['http://127.0.0.1:19000'], 'http://127.0.0.1:19000/')
|
|
261
|
+
).toBe('/');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('returns undefined for non-matching protocol', () => {
|
|
265
|
+
expect(extractPathFromURL(['scheme://'], 'foo://some/path')).toBeUndefined();
|
|
266
|
+
|
|
267
|
+
expect(extractPathFromURL(['scheme://'], 'foo:some/path')).toBeUndefined();
|
|
268
|
+
|
|
269
|
+
expect(extractPathFromURL(['scheme://'], 'foo:///some/path')).toBeUndefined();
|
|
270
|
+
|
|
271
|
+
expect(extractPathFromURL(['scheme:///'], 'foo:some/path')).toBeUndefined();
|
|
272
|
+
|
|
273
|
+
expect(extractPathFromURL(['scheme:'], 'foo:some/path')).toBeUndefined();
|
|
274
|
+
|
|
275
|
+
expect(extractPathFromURL(['scheme:'], 'foo://some/path')).toBeUndefined();
|
|
276
|
+
|
|
277
|
+
expect(extractPathFromURL(['scheme:'], 'foo:///some/path')).toBeUndefined();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('returns undefined for non-matching path', () => {
|
|
281
|
+
expect(
|
|
282
|
+
extractPathFromURL(['scheme://foo'], 'scheme://some/path')
|
|
283
|
+
).toBeUndefined();
|
|
284
|
+
|
|
285
|
+
expect(
|
|
286
|
+
extractPathFromURL(['scheme://foo'], 'scheme:some/path')
|
|
287
|
+
).toBeUndefined();
|
|
288
|
+
|
|
289
|
+
expect(
|
|
290
|
+
extractPathFromURL(['scheme://foo'], 'scheme:///some/path')
|
|
291
|
+
).toBeUndefined();
|
|
292
|
+
|
|
293
|
+
expect(
|
|
294
|
+
extractPathFromURL(['scheme:///foo'], 'scheme:some/path')
|
|
295
|
+
).toBeUndefined();
|
|
296
|
+
|
|
297
|
+
expect(
|
|
298
|
+
extractPathFromURL(['scheme:foo'], 'scheme:some/path')
|
|
299
|
+
).toBeUndefined();
|
|
300
|
+
|
|
301
|
+
expect(
|
|
302
|
+
extractPathFromURL(['scheme:foo'], 'scheme://some/path')
|
|
303
|
+
).toBeUndefined();
|
|
304
|
+
|
|
305
|
+
expect(
|
|
306
|
+
extractPathFromURL(['scheme:foo'], 'scheme:///some/path')
|
|
307
|
+
).toBeUndefined();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('returns undefined for non-matching host', () => {
|
|
311
|
+
expect(
|
|
312
|
+
extractPathFromURL(['scheme://example.com'], 'scheme://foo.com/some/path')
|
|
313
|
+
).toBeUndefined();
|
|
314
|
+
|
|
315
|
+
expect(
|
|
316
|
+
extractPathFromURL(['scheme://example.com'], 'scheme:foo.com/some/path')
|
|
317
|
+
).toBeUndefined();
|
|
318
|
+
|
|
319
|
+
expect(
|
|
320
|
+
extractPathFromURL(['scheme://example.com'], 'scheme:///foo.com/some/path')
|
|
321
|
+
).toBeUndefined();
|
|
322
|
+
|
|
323
|
+
expect(
|
|
324
|
+
extractPathFromURL(['scheme:///example.com'], 'scheme:foo.com/some/path')
|
|
325
|
+
).toBeUndefined();
|
|
326
|
+
|
|
327
|
+
expect(
|
|
328
|
+
extractPathFromURL(['scheme:example.com'], 'scheme:foo.com/some/path')
|
|
329
|
+
).toBeUndefined();
|
|
330
|
+
|
|
331
|
+
expect(
|
|
332
|
+
extractPathFromURL(['scheme:example.com'], 'scheme://foo.com/some/path')
|
|
333
|
+
).toBeUndefined();
|
|
334
|
+
|
|
335
|
+
expect(
|
|
336
|
+
extractPathFromURL(['scheme:example.com'], 'scheme:///foo.com/some/path')
|
|
337
|
+
).toBeUndefined();
|
|
338
|
+
|
|
339
|
+
expect(
|
|
340
|
+
extractPathFromURL(
|
|
341
|
+
['https://example.com'],
|
|
342
|
+
'https://example.com.evil.com/some/path'
|
|
343
|
+
)
|
|
344
|
+
).toBeUndefined();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('returns undefined for non-matching host with wildcard', () => {
|
|
348
|
+
expect(
|
|
349
|
+
extractPathFromURL(
|
|
350
|
+
['scheme://*.example.com'],
|
|
351
|
+
'scheme://test.foo.com/some/path'
|
|
352
|
+
)
|
|
353
|
+
).toBeUndefined();
|
|
354
|
+
|
|
355
|
+
expect(
|
|
356
|
+
extractPathFromURL(
|
|
357
|
+
['scheme://*.example.com'],
|
|
358
|
+
'scheme:test.foo.com/some/path'
|
|
359
|
+
)
|
|
360
|
+
).toBeUndefined();
|
|
361
|
+
|
|
362
|
+
expect(
|
|
363
|
+
extractPathFromURL(
|
|
364
|
+
['scheme://*.example.com'],
|
|
365
|
+
'scheme:///test.foo.com/some/path'
|
|
366
|
+
)
|
|
367
|
+
).toBeUndefined();
|
|
368
|
+
|
|
369
|
+
expect(
|
|
370
|
+
extractPathFromURL(
|
|
371
|
+
['scheme:///*.example.com'],
|
|
372
|
+
'scheme:test.foo.com/some/path'
|
|
373
|
+
)
|
|
374
|
+
).toBeUndefined();
|
|
375
|
+
|
|
376
|
+
expect(
|
|
377
|
+
extractPathFromURL(
|
|
378
|
+
['scheme:*.example.com'],
|
|
379
|
+
'scheme:test.foo.com/some/path'
|
|
380
|
+
)
|
|
381
|
+
).toBeUndefined();
|
|
382
|
+
|
|
383
|
+
expect(
|
|
384
|
+
extractPathFromURL(
|
|
385
|
+
['scheme:*.example.com'],
|
|
386
|
+
'scheme://test.foo.com/some/path'
|
|
387
|
+
)
|
|
388
|
+
).toBeUndefined();
|
|
389
|
+
|
|
390
|
+
expect(
|
|
391
|
+
extractPathFromURL(
|
|
392
|
+
['scheme:*.example.com'],
|
|
393
|
+
'scheme:///test.foo.com/some/path'
|
|
394
|
+
)
|
|
395
|
+
).toBeUndefined();
|
|
396
|
+
|
|
397
|
+
expect(
|
|
398
|
+
extractPathFromURL(
|
|
399
|
+
['https://*.example.com'],
|
|
400
|
+
'https://test.example.com.evil.com/some/path'
|
|
401
|
+
)
|
|
402
|
+
).toBeUndefined();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('returns undefined for path prefix collisions', () => {
|
|
406
|
+
expect(
|
|
407
|
+
extractPathFromURL(
|
|
408
|
+
['https://example.com/app'],
|
|
409
|
+
'https://example.com/application/path'
|
|
410
|
+
)
|
|
411
|
+
).toBeUndefined();
|
|
412
|
+
|
|
413
|
+
expect(
|
|
414
|
+
extractPathFromURL(
|
|
415
|
+
['https://example.com/app'],
|
|
416
|
+
'https://example.com/apply/path'
|
|
417
|
+
)
|
|
418
|
+
).toBeUndefined();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test('returns a valid search query when it has a url as param', () => {
|
|
422
|
+
expect(
|
|
423
|
+
extractPathFromURL(
|
|
424
|
+
['https://mysite.com'],
|
|
425
|
+
'https://mysite.com/readPolicy?url=https://test.com'
|
|
426
|
+
)
|
|
427
|
+
).toBe('/readPolicy?url=https://test.com');
|
|
428
|
+
|
|
429
|
+
expect(
|
|
430
|
+
extractPathFromURL(
|
|
431
|
+
['https://mysite.com'],
|
|
432
|
+
'https://mysite.com/readPolicy?url=https://test.com?param=1'
|
|
433
|
+
)
|
|
434
|
+
).toBe('/readPolicy?url=https://test.com?param=1');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test('supports wildcard prefix that matches any scheme', () => {
|
|
438
|
+
// Test with various schemes
|
|
439
|
+
expect(extractPathFromURL(['*'], 'myapp:some/path')).toBe('/some/path');
|
|
440
|
+
expect(extractPathFromURL(['*'], 'myapp://some/path')).toBe('/some/path');
|
|
441
|
+
expect(extractPathFromURL(['*'], 'myapp:///some/path')).toBe('/some/path');
|
|
442
|
+
|
|
443
|
+
expect(extractPathFromURL(['*'], 'customscheme:path/to/resource')).toBe(
|
|
444
|
+
'/path/to/resource'
|
|
445
|
+
);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test('supports wildcard prefix that matches any URL', () => {
|
|
449
|
+
expect(extractPathFromURL(['*'], 'http://mysite.com/some/path')).toBe(
|
|
450
|
+
'/some/path'
|
|
451
|
+
);
|
|
452
|
+
expect(extractPathFromURL(['*'], 'https://mysite.com/some/path')).toBe(
|
|
453
|
+
'/some/path'
|
|
454
|
+
);
|
|
455
|
+
expect(
|
|
456
|
+
extractPathFromURL(['*'], 'http://subdomain.mysite.com/some/path')
|
|
457
|
+
).toBe('/some/path');
|
|
458
|
+
expect(
|
|
459
|
+
extractPathFromURL(['*'], 'https://subdomain.mysite.com/some/path')
|
|
460
|
+
).toBe('/some/path');
|
|
461
|
+
|
|
462
|
+
expect(
|
|
463
|
+
extractPathFromURL(['*'], 'https://example.com/path?param=value')
|
|
464
|
+
).toBe('/path?param=value');
|
|
465
|
+
|
|
466
|
+
expect(extractPathFromURL(['*'], 'https://example.com')).toBe('/');
|
|
467
|
+
expect(extractPathFromURL(['*'], 'https://example.com/')).toBe('/');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test('supports wildcard prefix that matches IP addresses and ports', () => {
|
|
471
|
+
expect(extractPathFromURL(['*'], 'http://127.0.0.1:19000/some/path')).toBe(
|
|
472
|
+
'/some/path'
|
|
473
|
+
);
|
|
474
|
+
expect(extractPathFromURL(['*'], 'https://127.0.0.1:8080/some/path')).toBe(
|
|
475
|
+
'/some/path'
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
expect(extractPathFromURL(['*'], 'http://[::1]:8080/some/path')).toBe(
|
|
479
|
+
'/some/path'
|
|
480
|
+
);
|
|
481
|
+
expect(extractPathFromURL(['*'], 'https://[::1]:3000/api/test')).toBe(
|
|
482
|
+
'/api/test'
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
expect(extractPathFromURL(['*'], 'http://192.168.1.1:3000/admin')).toBe(
|
|
486
|
+
'/admin'
|
|
487
|
+
);
|
|
488
|
+
expect(extractPathFromURL(['*'], 'https://10.0.0.1:8443/secure/path')).toBe(
|
|
489
|
+
'/secure/path'
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
expect(extractPathFromURL(['*'], 'http://127.0.0.1:19000')).toBe('/');
|
|
493
|
+
expect(extractPathFromURL(['*'], 'http://127.0.0.1:19000/')).toBe('/');
|
|
494
|
+
|
|
495
|
+
expect(
|
|
496
|
+
extractPathFromURL(['*'], 'http://127.0.0.1:19000/path?param=value')
|
|
497
|
+
).toBe('/path?param=value');
|
|
498
|
+
expect(
|
|
499
|
+
extractPathFromURL(['*'], 'https://192.168.1.100:8080/api?token=123&id=456')
|
|
500
|
+
).toBe('/api?token=123&id=456');
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test('wildcard prefix does not match when no scheme is present', () => {
|
|
504
|
+
expect(extractPathFromURL(['*'], 'some/path/without/scheme')).toBeUndefined();
|
|
505
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { getStateFromPath } from '@react-navigation/core';
|
|
2
|
+
import { afterEach, expect, test, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
getInitialURL,
|
|
6
|
+
INIT_DATA_KEY,
|
|
7
|
+
RN_URL_EVENT,
|
|
8
|
+
subscribe,
|
|
9
|
+
URL_EVENT,
|
|
10
|
+
} from '../linking';
|
|
11
|
+
|
|
12
|
+
type Listener = (...args: any[]) => void;
|
|
13
|
+
|
|
14
|
+
/** Stands in for `lynx.__initData` and `GlobalEventEmitter`. */
|
|
15
|
+
function setupLynx(initNavigation?: Record<string, unknown>) {
|
|
16
|
+
const listeners = new Map<string, Set<Listener>>();
|
|
17
|
+
|
|
18
|
+
const emitter = {
|
|
19
|
+
addListener: (event: string, listener: Listener) => {
|
|
20
|
+
const set = listeners.get(event) ?? new Set();
|
|
21
|
+
set.add(listener);
|
|
22
|
+
listeners.set(event, set);
|
|
23
|
+
},
|
|
24
|
+
removeListener: (event: string, listener: Listener) => {
|
|
25
|
+
listeners.get(event)?.delete(listener);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const lynx = {
|
|
30
|
+
__initData: initNavigation
|
|
31
|
+
? { [INIT_DATA_KEY]: initNavigation }
|
|
32
|
+
: ({} as Record<string, unknown>),
|
|
33
|
+
getJSModule: (name: string) =>
|
|
34
|
+
name === 'GlobalEventEmitter' ? emitter : undefined,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
vi.stubGlobal('lynx', lynx);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
/** What the host does on `updateMetaData`. */
|
|
41
|
+
updateInitData(navigation: Record<string, unknown>) {
|
|
42
|
+
lynx.__initData = { [INIT_DATA_KEY]: navigation };
|
|
43
|
+
listeners.get('onDataChanged')?.forEach((l) => l());
|
|
44
|
+
},
|
|
45
|
+
emitUrl(url: string, event: string = URL_EVENT) {
|
|
46
|
+
listeners.get(event)?.forEach((l) => l({ url }));
|
|
47
|
+
},
|
|
48
|
+
listenerCount: (event: string) => listeners.get(event)?.size ?? 0,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.unstubAllGlobals();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('reads the launch route out of initData', () => {
|
|
57
|
+
setupLynx({ route: '/users/42?tab=posts' });
|
|
58
|
+
|
|
59
|
+
expect(getInitialURL()).toBe('/users/42?tab=posts');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('reports no launch route when the host set none', () => {
|
|
63
|
+
setupLynx();
|
|
64
|
+
|
|
65
|
+
expect(getInitialURL()).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('an initData update delivers the new route', () => {
|
|
69
|
+
const host = setupLynx({ route: '/', nonce: 1 });
|
|
70
|
+
const listener = vi.fn();
|
|
71
|
+
|
|
72
|
+
subscribe(listener);
|
|
73
|
+
host.updateInitData({ route: '/settings', nonce: 2 });
|
|
74
|
+
|
|
75
|
+
expect(listener).toHaveBeenCalledWith('/settings');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('the route the card started with is not delivered again', () => {
|
|
79
|
+
const host = setupLynx({ route: '/users/42', nonce: 1 });
|
|
80
|
+
const listener = vi.fn();
|
|
81
|
+
|
|
82
|
+
subscribe(listener);
|
|
83
|
+
// An unrelated initData change - a data refresh, say - must not re-navigate.
|
|
84
|
+
host.updateInitData({ route: '/users/42', nonce: 1 });
|
|
85
|
+
|
|
86
|
+
expect(listener).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('navigating twice to the same route works when the nonce moves', () => {
|
|
90
|
+
const host = setupLynx({ route: '/', nonce: 1 });
|
|
91
|
+
const listener = vi.fn();
|
|
92
|
+
|
|
93
|
+
subscribe(listener);
|
|
94
|
+
host.updateInitData({ route: '/users/42', nonce: 2 });
|
|
95
|
+
host.updateInitData({ route: '/users/42', nonce: 3 });
|
|
96
|
+
|
|
97
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
98
|
+
expect(listener).toHaveBeenNthCalledWith(2, '/users/42');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a url event delivers the route', () => {
|
|
102
|
+
const host = setupLynx();
|
|
103
|
+
const listener = vi.fn();
|
|
104
|
+
|
|
105
|
+
subscribe(listener);
|
|
106
|
+
host.emitUrl('/settings');
|
|
107
|
+
|
|
108
|
+
expect(listener).toHaveBeenCalledWith('/settings');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('the event name is namespaced, since GlobalEventEmitter is shared', () => {
|
|
112
|
+
expect(URL_EVENT).toBe('reactnavigation.url');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('still accepts the bare `url` event React Native hosts emit', () => {
|
|
116
|
+
const host = setupLynx();
|
|
117
|
+
const listener = vi.fn();
|
|
118
|
+
|
|
119
|
+
subscribe(listener);
|
|
120
|
+
host.emitUrl('/settings', RN_URL_EVENT);
|
|
121
|
+
|
|
122
|
+
expect(listener).toHaveBeenCalledWith('/settings');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('unsubscribing detaches every listener', () => {
|
|
126
|
+
const host = setupLynx({ route: '/' });
|
|
127
|
+
|
|
128
|
+
const unsubscribe = subscribe(vi.fn());
|
|
129
|
+
|
|
130
|
+
expect(host.listenerCount('onDataChanged')).toBe(1);
|
|
131
|
+
expect(host.listenerCount(URL_EVENT)).toBe(1);
|
|
132
|
+
expect(host.listenerCount(RN_URL_EVENT)).toBe(1);
|
|
133
|
+
|
|
134
|
+
unsubscribe();
|
|
135
|
+
|
|
136
|
+
expect(host.listenerCount('onDataChanged')).toBe(0);
|
|
137
|
+
expect(host.listenerCount(URL_EVENT)).toBe(0);
|
|
138
|
+
expect(host.listenerCount(RN_URL_EVENT)).toBe(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('survives a host that offers no GlobalEventEmitter', () => {
|
|
142
|
+
vi.stubGlobal('lynx', { __initData: {} });
|
|
143
|
+
|
|
144
|
+
expect(() => subscribe(vi.fn())()).not.toThrow();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('the route it produces is what core turns into navigation state', () => {
|
|
148
|
+
setupLynx({ route: '/users/42?tab=posts' });
|
|
149
|
+
|
|
150
|
+
const state = getStateFromPath(getInitialURL()!, {
|
|
151
|
+
screens: { Home: '', Profile: 'users/:id' },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(state).toEqual({
|
|
155
|
+
routes: [
|
|
156
|
+
{
|
|
157
|
+
name: 'Profile',
|
|
158
|
+
params: { id: '42', tab: 'posts' },
|
|
159
|
+
path: '/users/42?tab=posts',
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import escapeStringRegexp from 'escape-string-regexp';
|
|
2
|
+
|
|
3
|
+
import type { LinkingPrefix } from './useLinking';
|
|
4
|
+
|
|
5
|
+
/** Verbatim from `@react-navigation/native`, which cannot be imported here. */
|
|
6
|
+
export function extractPathFromURL(prefixes: LinkingPrefix[], url: string) {
|
|
7
|
+
for (const prefix of prefixes) {
|
|
8
|
+
let prefixRegex;
|
|
9
|
+
|
|
10
|
+
if (prefix === '*') {
|
|
11
|
+
prefixRegex = /^(((https?:\/\/)[^/]+)|([^/]+:(\/\/)?))/;
|
|
12
|
+
} else {
|
|
13
|
+
const protocol = prefix.match(/^[^:]+:/)?.[0] ?? '';
|
|
14
|
+
const host = prefix
|
|
15
|
+
.replace(new RegExp(`^${escapeStringRegexp(protocol)}`), '')
|
|
16
|
+
.replace(/\/+/g, '/') // Replace multiple slash (//) with single ones
|
|
17
|
+
.replace(/^\//, ''); // Remove extra leading slash
|
|
18
|
+
|
|
19
|
+
prefixRegex = new RegExp(
|
|
20
|
+
`^${escapeStringRegexp(protocol)}(/)*${host
|
|
21
|
+
.split('.')
|
|
22
|
+
.map((it) => (it === '*' ? '[^/?#]+' : escapeStringRegexp(it)))
|
|
23
|
+
.join('\\.')}${
|
|
24
|
+
host === '' || host.endsWith('/') ? '' : '(?=$|[/?#])'
|
|
25
|
+
}`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const [originAndPath = '', ...searchParams] = url.split('?');
|
|
30
|
+
|
|
31
|
+
if (prefixRegex.test(originAndPath)) {
|
|
32
|
+
const result = originAndPath
|
|
33
|
+
.replace(prefixRegex, '')
|
|
34
|
+
.replace(/\/+/g, '/')
|
|
35
|
+
.concat(searchParams.length ? `?${searchParams.join('?')}` : '');
|
|
36
|
+
|
|
37
|
+
return result.startsWith('/') ? result : `/${result}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -10,4 +10,14 @@ export {
|
|
|
10
10
|
export { DarkTheme } from './theming/DarkTheme';
|
|
11
11
|
export { LightTheme as DefaultTheme } from './theming/LightTheme';
|
|
12
12
|
|
|
13
|
+
export {
|
|
14
|
+
getInitialURL,
|
|
15
|
+
INIT_DATA_KEY,
|
|
16
|
+
RN_URL_EVENT,
|
|
17
|
+
subscribe,
|
|
18
|
+
URL_EVENT,
|
|
19
|
+
type NavigationInitData,
|
|
20
|
+
} from './linking';
|
|
21
|
+
export type { LinkingOptions, LinkingPrefix } from './useLinking';
|
|
22
|
+
|
|
13
23
|
export * from '@react-navigation/core';
|
package/src/linking.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Lynx half of React Navigation's linking: a card is not the process that
|
|
3
|
+
* receives the URL, so the host hands it over either through
|
|
4
|
+
* `initData.__navigation` or through a global event.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const INIT_DATA_KEY = '__navigation';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Namespaced, unlike React Native's bare `url`: `GlobalEventEmitter` is one
|
|
11
|
+
* namespace shared by the whole card and its host.
|
|
12
|
+
*/
|
|
13
|
+
export const URL_EVENT = 'reactnavigation.url';
|
|
14
|
+
|
|
15
|
+
export const RN_URL_EVENT = 'url';
|
|
16
|
+
|
|
17
|
+
export type NavigationInitData = {
|
|
18
|
+
route?: string | undefined;
|
|
19
|
+
/** Anything that changes per navigation; see `subscribe`. */
|
|
20
|
+
nonce?: string | number | undefined;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type GlobalEventEmitter = {
|
|
24
|
+
addListener: (event: string, listener: (...args: any[]) => void) => void;
|
|
25
|
+
removeListener: (event: string, listener: (...args: any[]) => void) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const DATA_CHANGED_EVENT = 'onDataChanged';
|
|
29
|
+
|
|
30
|
+
function readNavigationInitData(): NavigationInitData | undefined {
|
|
31
|
+
const initData = (lynx as { __initData?: Record<string, unknown> }).__initData;
|
|
32
|
+
return initData?.[INIT_DATA_KEY] as NavigationInitData | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getEmitter(): GlobalEventEmitter | undefined {
|
|
36
|
+
// Background thread only, and absent under test renderers.
|
|
37
|
+
return lynx.getJSModule?.('GlobalEventEmitter') as
|
|
38
|
+
| GlobalEventEmitter
|
|
39
|
+
| undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Synchronous, unlike React Native's `Linking.getInitialURL()`, so the first
|
|
44
|
+
* render already lands on the right route.
|
|
45
|
+
*/
|
|
46
|
+
export function getInitialURL(): string | undefined {
|
|
47
|
+
return readNavigationInitData()?.route;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function subscribe(listener: (url: string) => void): () => void {
|
|
51
|
+
const emitter = getEmitter();
|
|
52
|
+
|
|
53
|
+
if (!emitter) {
|
|
54
|
+
return () => {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let lastSeen = readNavigationInitData()?.nonce ?? getInitialURL();
|
|
58
|
+
|
|
59
|
+
const onDataChanged = () => {
|
|
60
|
+
const next = readNavigationInitData();
|
|
61
|
+
|
|
62
|
+
if (!next?.route) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// initData is state, not an event: without the nonce, navigating to the
|
|
67
|
+
// same route twice would leave it untouched and be dropped.
|
|
68
|
+
const marker = next.nonce ?? next.route;
|
|
69
|
+
|
|
70
|
+
if (marker === lastSeen) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
lastSeen = marker;
|
|
75
|
+
listener(next.route);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// `sendGlobalEvent(name, JavaOnlyArray)` reaches JS as either the array's
|
|
79
|
+
// first element or the array itself, and a host may send a bare string.
|
|
80
|
+
const onUrl = (payload: unknown) => {
|
|
81
|
+
const first = Array.isArray(payload) ? payload[0] : payload;
|
|
82
|
+
const url =
|
|
83
|
+
typeof first === 'string'
|
|
84
|
+
? first
|
|
85
|
+
: (first as { url?: string } | undefined)?.url;
|
|
86
|
+
|
|
87
|
+
if (url) {
|
|
88
|
+
listener(url);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
emitter.addListener(DATA_CHANGED_EVENT, onDataChanged);
|
|
93
|
+
emitter.addListener(URL_EVENT, onUrl);
|
|
94
|
+
emitter.addListener(RN_URL_EVENT, onUrl);
|
|
95
|
+
|
|
96
|
+
return () => {
|
|
97
|
+
emitter.removeListener(DATA_CHANGED_EVENT, onDataChanged);
|
|
98
|
+
emitter.removeListener(URL_EVENT, onUrl);
|
|
99
|
+
emitter.removeListener(RN_URL_EVENT, onUrl);
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getActionFromState as getActionFromStateDefault,
|
|
3
|
+
getStateFromPath as getStateFromPathDefault,
|
|
4
|
+
type NavigationContainerRef,
|
|
5
|
+
type ParamListBase,
|
|
6
|
+
type PartialState,
|
|
7
|
+
type NavigationState,
|
|
8
|
+
} from '@react-navigation/core';
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
|
|
11
|
+
import { extractPathFromURL } from './extractPathFromURL';
|
|
12
|
+
import {
|
|
13
|
+
getInitialURL as getInitialURLDefault,
|
|
14
|
+
subscribe as subscribeDefault,
|
|
15
|
+
} from './linking';
|
|
16
|
+
|
|
17
|
+
// core keeps its `Options` type internal; derive it from the consumer so it
|
|
18
|
+
// cannot drift.
|
|
19
|
+
type LinkingConfig<ParamList extends {}> = NonNullable<
|
|
20
|
+
Parameters<typeof getStateFromPathDefault<ParamList>>[1]
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
export type LinkingPrefix = '*' | (string & {});
|
|
24
|
+
|
|
25
|
+
export type LinkingOptions<ParamList extends {}> = {
|
|
26
|
+
/** Defaults to true when a config is given. */
|
|
27
|
+
enabled?: boolean | undefined;
|
|
28
|
+
prefixes?: LinkingPrefix[] | undefined;
|
|
29
|
+
/** Rejects a URL before its prefix is stripped. */
|
|
30
|
+
filter?: ((url: string) => boolean) | undefined;
|
|
31
|
+
config?: LinkingConfig<ParamList> | undefined;
|
|
32
|
+
/** Overrides where the launch URL comes from. */
|
|
33
|
+
getInitialURL?: (() => string | undefined) | undefined;
|
|
34
|
+
/** Overrides how later URLs arrive. */
|
|
35
|
+
subscribe?:
|
|
36
|
+
| ((listener: (url: string) => void) => undefined | void | (() => void))
|
|
37
|
+
| undefined;
|
|
38
|
+
getStateFromPath?: typeof getStateFromPathDefault | undefined;
|
|
39
|
+
getActionFromState?: typeof getActionFromStateDefault | undefined;
|
|
40
|
+
} & { [key: string]: unknown };
|
|
41
|
+
|
|
42
|
+
/** The `getStateFromHref` contract of `@react-navigation/native`. */
|
|
43
|
+
function extractPath(
|
|
44
|
+
url: string,
|
|
45
|
+
prefixes: LinkingPrefix[] | undefined,
|
|
46
|
+
filter: ((url: string) => boolean) | undefined
|
|
47
|
+
) {
|
|
48
|
+
if (url.startsWith('/')) {
|
|
49
|
+
return url;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (filter && !filter(url)) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (prefixes == null || prefixes.length === 0) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return extractPathFromURL(prefixes, url);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The Lynx counterpart of `useLinking` in `@react-navigation/native`. */
|
|
64
|
+
export function useLinking<ParamList extends {} = ParamListBase>(
|
|
65
|
+
ref: React.RefObject<NavigationContainerRef<ParamListBase> | null>,
|
|
66
|
+
options: LinkingOptions<ParamList> | undefined
|
|
67
|
+
) {
|
|
68
|
+
const {
|
|
69
|
+
enabled = options?.config != null,
|
|
70
|
+
// Same default as React Native: strip whatever scheme the host used.
|
|
71
|
+
prefixes = ['*'],
|
|
72
|
+
filter,
|
|
73
|
+
config,
|
|
74
|
+
getInitialURL = getInitialURLDefault,
|
|
75
|
+
subscribe = subscribeDefault,
|
|
76
|
+
getStateFromPath = getStateFromPathDefault,
|
|
77
|
+
getActionFromState = getActionFromStateDefault,
|
|
78
|
+
} = options ?? {};
|
|
79
|
+
|
|
80
|
+
const optionsRef = React.useRef({
|
|
81
|
+
enabled,
|
|
82
|
+
prefixes,
|
|
83
|
+
filter,
|
|
84
|
+
config,
|
|
85
|
+
getStateFromPath,
|
|
86
|
+
getActionFromState,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
React.useEffect(() => {
|
|
90
|
+
optionsRef.current = {
|
|
91
|
+
enabled,
|
|
92
|
+
prefixes,
|
|
93
|
+
filter,
|
|
94
|
+
config,
|
|
95
|
+
getStateFromPath,
|
|
96
|
+
getActionFromState,
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const getStateFromURL = React.useCallback(
|
|
101
|
+
(url: string | undefined, previous: NavigationState | undefined) => {
|
|
102
|
+
const current = optionsRef.current;
|
|
103
|
+
|
|
104
|
+
if (!url) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const path = extractPath(url, current.prefixes, current.filter);
|
|
109
|
+
|
|
110
|
+
if (path == null) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
return current.getStateFromPath(path, current.config, previous);
|
|
116
|
+
} catch {
|
|
117
|
+
// A malformed link should not take the card down with it.
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
[]
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const getInitialState = React.useCallback(():
|
|
125
|
+
| PartialState<NavigationState>
|
|
126
|
+
| undefined => {
|
|
127
|
+
if (!optionsRef.current.enabled) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return getStateFromURL(getInitialURL(), undefined);
|
|
132
|
+
// Read once on mount: a later identity change must not re-run the route.
|
|
133
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
134
|
+
}, [getStateFromURL]);
|
|
135
|
+
|
|
136
|
+
React.useEffect(() => {
|
|
137
|
+
if (!enabled) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const listener = (url: string) => {
|
|
142
|
+
const navigation = ref.current;
|
|
143
|
+
|
|
144
|
+
if (!navigation) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const rootState = navigation.getRootState();
|
|
149
|
+
const state = getStateFromURL(url, rootState);
|
|
150
|
+
|
|
151
|
+
if (!state) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const action = optionsRef.current.getActionFromState(
|
|
156
|
+
state,
|
|
157
|
+
optionsRef.current.config
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
if (action === undefined) {
|
|
161
|
+
navigation.resetRoot(state);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
navigation.dispatch({ target: rootState?.key, ...action });
|
|
167
|
+
} catch (e) {
|
|
168
|
+
console.warn(
|
|
169
|
+
`An error occurred when trying to handle the link '${url}': ${
|
|
170
|
+
e instanceof Error ? e.message : String(e)
|
|
171
|
+
}`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return subscribe(listener) ?? undefined;
|
|
177
|
+
}, [enabled, ref, getStateFromURL, subscribe]);
|
|
178
|
+
|
|
179
|
+
return { getInitialState };
|
|
180
|
+
}
|