@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-navigation/lynx",
3
- "version": "0.2.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.125.0",
43
- "@lynx-js/testing-environment": "^0.3.3",
44
- "@lynx-js/types": "^4.1.0",
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
- * It is the platform layer's entry point: everything a navigator needs that is
32
- * not navigation state itself - the theme, and eventually deep linking and
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
- {...rest}
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
+ }