@react-navigation/lynx 0.0.0 → 0.1.0

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.
@@ -0,0 +1,506 @@
1
+ // Ported from react-navigation's new native stack. The reducer is
2
+ // platform-agnostic, so only the imports and type names change; `render`
3
+ // comes from ReactLynx's testing library instead of react-native's.
4
+ //
5
+ // One deliberate divergence: upstream's mock descriptors set `title`, a header
6
+ // option this package does not have yet. Those two assertions use
7
+ // `presentation` instead - same shape, an option that exists here.
8
+ //
9
+ // packages/native-stack/src/next/views/__tests__/NativeStackViewState.test.tsx @ 7b32fd92d
10
+ import { expect, test } from 'vitest';
11
+ import type {
12
+ ParamListBase,
13
+ Route,
14
+ StackNavigationState,
15
+ } from '@react-navigation/core';
16
+ import { render } from '@lynx-js/react/testing-library';
17
+
18
+ import type {
19
+ LynxStackDescriptorMap,
20
+ LynxStackNavigationProp,
21
+ } from '../../types';
22
+ import {
23
+ type LynxStackViewState,
24
+ reducer,
25
+ useViewState,
26
+ } from '../LynxStackViewState';
27
+
28
+ const createRoute = (name: string): Route<string> => ({
29
+ key: name,
30
+ name,
31
+ });
32
+
33
+ const A = createRoute('A');
34
+ const B = createRoute('B');
35
+ const C = createRoute('C');
36
+ const D = createRoute('D');
37
+ const E = createRoute('E');
38
+
39
+ function getParent(): LynxStackNavigationProp<ParamListBase> {
40
+ return navigation;
41
+ }
42
+
43
+ const navigation = {
44
+ addListener: () => () => {},
45
+ canGoBack: () => false,
46
+ dispatch: () => {},
47
+ getParent,
48
+ getState: () => ({
49
+ stale: false,
50
+ type: 'stack',
51
+ key: 'stack',
52
+ index: 0,
53
+ routeNames: [],
54
+ routes: [],
55
+ retainedRouteKeys: [],
56
+ }),
57
+ goBack: () => {},
58
+ isFocused: () => true,
59
+ navigate: () => {},
60
+ pop: () => {},
61
+ popTo: () => {},
62
+ popToTop: () => {},
63
+ preload: () => {},
64
+ push: () => {},
65
+ pushParams: () => {},
66
+ removeListener: () => {},
67
+ replace: () => {},
68
+ replaceParams: () => {},
69
+ reset: () => {},
70
+ retain: () => {},
71
+ setOptions: () => {},
72
+ setParams: () => {},
73
+ } satisfies LynxStackNavigationProp<ParamListBase>;
74
+
75
+ const descriptors = {
76
+ A: {
77
+ navigation,
78
+ options: {},
79
+ render: () => <></>,
80
+ route: A,
81
+ },
82
+ B: {
83
+ navigation,
84
+ options: {},
85
+ render: () => <></>,
86
+ route: B,
87
+ },
88
+ C: {
89
+ navigation,
90
+ options: {},
91
+ render: () => <></>,
92
+ route: C,
93
+ },
94
+ D: {
95
+ navigation,
96
+ options: {},
97
+ render: () => <></>,
98
+ route: D,
99
+ },
100
+ E: {
101
+ navigation,
102
+ options: {},
103
+ render: () => <></>,
104
+ route: E,
105
+ },
106
+ } satisfies LynxStackDescriptorMap;
107
+
108
+ function createState(): LynxStackViewState {
109
+ const routes = [A, B, C];
110
+
111
+ return {
112
+ previous: { index: 2, routes, descriptors },
113
+ renderedRoutes: routes,
114
+ poppedByKey: new Map(),
115
+ nativelyDismissedRouteKeys: new Set(),
116
+ };
117
+ }
118
+
119
+ const syncState = (
120
+ state: LynxStackViewState,
121
+ routes: Route<string>[],
122
+ nextDescriptors: LynxStackDescriptorMap = descriptors
123
+ ) =>
124
+ reducer(state, {
125
+ type: 'SYNC_STATE',
126
+ index: routes.length - 1,
127
+ routes,
128
+ descriptors: nextDescriptors,
129
+ });
130
+
131
+ const getRouteKeys = (state: LynxStackViewState) =>
132
+ state.renderedRoutes.map((route) => route.key);
133
+
134
+ const getPoppedRouteKeys = (state: LynxStackViewState) =>
135
+ state.renderedRoutes
136
+ .filter((route) => state.poppedByKey.has(route.key))
137
+ .map((route) => route.key);
138
+
139
+ const createNavigationState = (
140
+ routes: Route<string>[]
141
+ ): StackNavigationState<ParamListBase> => ({
142
+ stale: false,
143
+ type: 'stack',
144
+ key: 'stack',
145
+ index: routes.length - 1,
146
+ routeNames: routes.map((route) => route.name),
147
+ routes,
148
+ retainedRouteKeys: [],
149
+ });
150
+
151
+ test('preserves route order across consecutive pops', () => {
152
+ let state = syncState(createState(), [A, B]);
153
+
154
+ state = syncState(state, [A]);
155
+
156
+ expect(getRouteKeys(state)).toEqual(['A', 'B', 'C']);
157
+ expect(getPoppedRouteKeys(state)).toEqual(['B', 'C']);
158
+ });
159
+
160
+ test('retains all routes removed by pop-to-top in their original order', () => {
161
+ let state = syncState(createState(), [A]);
162
+
163
+ expect(getRouteKeys(state)).toEqual(['A', 'B', 'C']);
164
+ expect(getPoppedRouteKeys(state)).toEqual(['B', 'C']);
165
+
166
+ state = reducer(state, {
167
+ type: 'REMOVE_POPPED_ROUTE',
168
+ key: 'C',
169
+ });
170
+
171
+ expect(getRouteKeys(state)).toEqual(['A', 'B']);
172
+ expect(getPoppedRouteKeys(state)).toEqual(['B']);
173
+ });
174
+
175
+ test('keeps a removed route above its replacement while it closes', () => {
176
+ const state = syncState(createState(), [A, B, D]);
177
+
178
+ expect(getRouteKeys(state)).toEqual(['A', 'B', 'D', 'C']);
179
+ expect(getPoppedRouteKeys(state)).toEqual(['C']);
180
+ expect(state.poppedByKey.get('C')?.focusedReplacementKey).toBe('D');
181
+ });
182
+
183
+ test('recognizes a preloaded route as the replacement', () => {
184
+ const routes = [A, B, C, D];
185
+ const state = syncState(
186
+ {
187
+ previous: { index: 2, routes, descriptors },
188
+ renderedRoutes: routes,
189
+ poppedByKey: new Map(),
190
+ nativelyDismissedRouteKeys: new Set(),
191
+ },
192
+ [A, B, D]
193
+ );
194
+
195
+ expect(getRouteKeys(state)).toEqual(['A', 'B', 'C', 'D']);
196
+ expect(state.poppedByKey.get('C')?.focusedReplacementKey).toBe('D');
197
+ });
198
+
199
+ test('does not retarget a pending replacement after a later push', () => {
200
+ let state = syncState(createState(), [A, B, D]);
201
+
202
+ state = syncState(state, [A, B, D, E]);
203
+
204
+ expect(getRouteKeys(state)).toEqual(['A', 'B', 'D', 'E', 'C']);
205
+ expect(state.poppedByKey.get('C')?.focusedReplacementKey).toBe('D');
206
+ });
207
+
208
+ test('tracks consecutive replacements independently', () => {
209
+ let state = syncState(createState(), [A, B, D]);
210
+
211
+ state = syncState(state, [A, B, E]);
212
+
213
+ expect(getRouteKeys(state)).toEqual(['A', 'B', 'E', 'D', 'C']);
214
+ expect(state.poppedByKey.get('C')?.focusedReplacementKey).toBe('D');
215
+ expect(state.poppedByKey.get('D')?.focusedReplacementKey).toBe('E');
216
+ });
217
+
218
+ test('keeps a removed middle route above its replacement while it closes', () => {
219
+ const state = syncState(createState(), [A, D, C]);
220
+
221
+ expect(getRouteKeys(state)).toEqual(['A', 'D', 'B', 'C']);
222
+ expect(getPoppedRouteKeys(state)).toEqual(['B']);
223
+ expect(state.poppedByKey.get('B')?.focusedReplacementKey).toBeUndefined();
224
+ });
225
+
226
+ test('rejects reordering retained routes', () => {
227
+ expect(() => syncState(createState(), [C, A])).toThrow(
228
+ 'Changing the order of active routes is not supported in native stack.'
229
+ );
230
+ });
231
+
232
+ test('allows a preloaded route to move into the active stack', () => {
233
+ const routes = [A, B, C];
234
+ const state = reducer(
235
+ {
236
+ previous: { index: 0, routes, descriptors },
237
+ renderedRoutes: routes,
238
+ poppedByKey: new Map(),
239
+ nativelyDismissedRouteKeys: new Set(),
240
+ },
241
+ {
242
+ type: 'SYNC_STATE',
243
+ index: 1,
244
+ routes: [A, C, B],
245
+ descriptors,
246
+ }
247
+ );
248
+
249
+ expect(getRouteKeys(state)).toEqual(['A', 'C', 'B']);
250
+ expect(getPoppedRouteKeys(state)).toEqual([]);
251
+ });
252
+
253
+ test('keeps removed routes above a root replacement while they close', () => {
254
+ const state = syncState(createState(), [D]);
255
+
256
+ expect(getRouteKeys(state)).toEqual(['D', 'A', 'B', 'C']);
257
+ expect(getPoppedRouteKeys(state)).toEqual(['A', 'B', 'C']);
258
+ expect(state.poppedByKey.get('A')?.focusedReplacementKey).toBe('D');
259
+ expect(state.poppedByKey.get('B')?.focusedReplacementKey).toBe('D');
260
+ expect(state.poppedByKey.get('C')?.focusedReplacementKey).toBe('D');
261
+ });
262
+
263
+ test('stops retaining a route when its key returns to navigation state', () => {
264
+ let state = syncState(createState(), [A, B]);
265
+
266
+ state = syncState(state, [A, B, C]);
267
+
268
+ expect(getRouteKeys(state)).toEqual(['A', 'B', 'C']);
269
+ expect(getPoppedRouteKeys(state)).toEqual([]);
270
+ });
271
+
272
+ test('does not retain routes that were dismissed natively', () => {
273
+ let state = reducer(createState(), {
274
+ type: 'ADD_NATIVELY_DISMISSED_ROUTES',
275
+ keys: ['C'],
276
+ });
277
+
278
+ state = syncState(state, [A, B]);
279
+
280
+ expect(getRouteKeys(state)).toEqual(['A', 'B']);
281
+ expect(getPoppedRouteKeys(state)).toEqual([]);
282
+ expect(state.nativelyDismissedRouteKeys).toEqual(new Set());
283
+ });
284
+
285
+ test('retains a natively dismissed route if it stayed in navigation state', () => {
286
+ let state = reducer(createState(), {
287
+ type: 'ADD_NATIVELY_DISMISSED_ROUTES',
288
+ keys: ['C'],
289
+ });
290
+
291
+ // The pop action following the native dismissal didn't remove the route, e.g.
292
+ // when a 'beforeRemove' listener prevented it.
293
+ state = syncState(state, [A, B, C]);
294
+
295
+ expect(state.nativelyDismissedRouteKeys).toEqual(new Set());
296
+
297
+ state = syncState(state, [A, B]);
298
+
299
+ expect(getRouteKeys(state)).toEqual(['A', 'B', 'C']);
300
+ expect(getPoppedRouteKeys(state)).toEqual(['C']);
301
+ });
302
+
303
+ test('does not retain detached routes removed from navigation state', () => {
304
+ const initialState: LynxStackViewState = {
305
+ previous: { index: 1, routes: [A, B, C], descriptors },
306
+ renderedRoutes: [A, B, C],
307
+ poppedByKey: new Map(),
308
+ nativelyDismissedRouteKeys: new Set(),
309
+ };
310
+
311
+ const state = syncState(initialState, [A, B]);
312
+
313
+ expect(getRouteKeys(state)).toEqual(['A', 'B']);
314
+ expect(getPoppedRouteKeys(state)).toEqual([]);
315
+ });
316
+
317
+ test('retains a popped route and its previous descriptor until dismissal', () => {
318
+ const state = syncState(createState(), [A, B], {
319
+ A: descriptors.A,
320
+ B: descriptors.B,
321
+ });
322
+
323
+ expect(state.poppedByKey.get('C')).toEqual({
324
+ descriptor: descriptors.C,
325
+ previousDescriptor: descriptors.B,
326
+ focusedReplacementKey: undefined,
327
+ });
328
+
329
+ const dismissedState = reducer(state, {
330
+ type: 'REMOVE_POPPED_ROUTE',
331
+ key: 'C',
332
+ });
333
+
334
+ expect(getRouteKeys(dismissedState)).toEqual(['A', 'B']);
335
+ expect(getPoppedRouteKeys(dismissedState)).toEqual([]);
336
+ });
337
+
338
+ test('uses the current descriptor for the previous route', () => {
339
+ const previousDescriptor = {
340
+ ...descriptors.B,
341
+ options: { presentation: 'card' as const },
342
+ };
343
+ const state = syncState(createState(), [A, B], {
344
+ A: descriptors.A,
345
+ B: previousDescriptor,
346
+ });
347
+
348
+ expect(state.poppedByKey.get('C')?.previousDescriptor).toBe(
349
+ previousDescriptor
350
+ );
351
+ });
352
+
353
+ test('uses the old descriptor when the previous route was also removed', () => {
354
+ const state = syncState(createState(), [A], {
355
+ A: descriptors.A,
356
+ });
357
+
358
+ expect(state.poppedByKey.get('C')?.previousDescriptor).toBe(descriptors.B);
359
+ });
360
+
361
+ test('uses the latest descriptor snapshot when a route is popped', () => {
362
+ const descriptor = {
363
+ ...descriptors.C,
364
+ options: { presentation: 'card' as const },
365
+ };
366
+ const updatedDescriptors = {
367
+ ...descriptors,
368
+ C: descriptor,
369
+ };
370
+ let state = reducer(createState(), {
371
+ type: 'SYNC_DESCRIPTORS',
372
+ descriptors: updatedDescriptors,
373
+ });
374
+
375
+ state = syncState(state, [A, B]);
376
+
377
+ expect(state.poppedByKey.get('C')?.descriptor).toBe(descriptor);
378
+ });
379
+
380
+ test('preserves native dismissal state when descriptors change', () => {
381
+ let state = reducer(createState(), {
382
+ type: 'ADD_NATIVELY_DISMISSED_ROUTES',
383
+ keys: ['C'],
384
+ });
385
+
386
+ state = reducer(state, {
387
+ type: 'SYNC_DESCRIPTORS',
388
+ descriptors: { ...descriptors },
389
+ });
390
+
391
+ expect(state.nativelyDismissedRouteKeys).toEqual(new Set(['C']));
392
+ });
393
+
394
+ test('renders content once while synchronizing descriptor changes', async () => {
395
+ let renderCount = 0;
396
+ let renderedDescriptors: LynxStackDescriptorMap | undefined;
397
+
398
+ const Content = ({
399
+ currentDescriptors,
400
+ }: {
401
+ currentDescriptors: LynxStackDescriptorMap;
402
+ }) => {
403
+ renderCount++;
404
+ renderedDescriptors = currentDescriptors;
405
+
406
+ return null;
407
+ };
408
+
409
+ const navigationState = createNavigationState([A, B, C]);
410
+
411
+ const TestView = ({
412
+ currentDescriptors,
413
+ }: {
414
+ currentDescriptors: LynxStackDescriptorMap;
415
+ }) => {
416
+ const [{ previous }] = useViewState({
417
+ state: navigationState,
418
+ descriptors: currentDescriptors,
419
+ });
420
+
421
+ return <Content currentDescriptors={previous.descriptors} />;
422
+ };
423
+
424
+ const screen = await render(<TestView currentDescriptors={descriptors} />);
425
+
426
+ const nextDescriptors = { ...descriptors };
427
+
428
+ await screen.rerender(<TestView currentDescriptors={nextDescriptors} />);
429
+
430
+ expect(renderCount).toBe(2);
431
+ expect(renderedDescriptors).toBe(nextDescriptors);
432
+ });
433
+
434
+ test('renders content once while synchronizing a back state', async () => {
435
+ let renderCount = 0;
436
+ let renderedRouteKeys: string[] = [];
437
+ let poppedDescriptor: LynxStackDescriptorMap[string] | undefined;
438
+
439
+ const Content = ({
440
+ renderedRoutes,
441
+ poppedByKey,
442
+ currentDescriptors,
443
+ }: Pick<LynxStackViewState, 'renderedRoutes' | 'poppedByKey'> & {
444
+ currentDescriptors: LynxStackDescriptorMap;
445
+ }) => {
446
+ renderCount++;
447
+
448
+ for (const route of renderedRoutes) {
449
+ const descriptor =
450
+ currentDescriptors[route.key] ?? poppedByKey.get(route.key)?.descriptor;
451
+
452
+ if (descriptor == null) {
453
+ throw new Error(`Missing descriptor for ${route.key}`);
454
+ }
455
+ }
456
+
457
+ renderedRouteKeys = renderedRoutes.map((route) => route.key);
458
+ poppedDescriptor = poppedByKey.get('C')?.descriptor;
459
+
460
+ return null;
461
+ };
462
+
463
+ const TestView = ({
464
+ state,
465
+ currentDescriptors,
466
+ }: {
467
+ state: StackNavigationState<ParamListBase>;
468
+ currentDescriptors: LynxStackDescriptorMap;
469
+ }) => {
470
+ const [{ renderedRoutes, poppedByKey }] = useViewState({
471
+ state,
472
+ descriptors: currentDescriptors,
473
+ });
474
+
475
+ return (
476
+ <Content
477
+ renderedRoutes={renderedRoutes}
478
+ poppedByKey={poppedByKey}
479
+ currentDescriptors={currentDescriptors}
480
+ />
481
+ );
482
+ };
483
+
484
+ const screen = await render(
485
+ <TestView
486
+ state={createNavigationState([A, B, C])}
487
+ currentDescriptors={descriptors}
488
+ />
489
+ );
490
+
491
+ const nextDescriptors = {
492
+ A: descriptors.A,
493
+ B: descriptors.B,
494
+ };
495
+
496
+ await screen.rerender(
497
+ <TestView
498
+ state={createNavigationState([A, B])}
499
+ currentDescriptors={nextDescriptors}
500
+ />
501
+ );
502
+
503
+ expect(renderCount).toBe(2);
504
+ expect(renderedRouteKeys).toEqual(['A', 'B', 'C']);
505
+ expect(poppedDescriptor).toBe(descriptors.C);
506
+ });
@@ -0,0 +1,16 @@
1
+ import type { LynxTheme } from '../types';
2
+
3
+ import { fonts } from './fonts';
4
+
5
+ export const DarkTheme = {
6
+ dark: true,
7
+ colors: {
8
+ primary: 'rgb(10, 132, 255)',
9
+ background: 'rgb(0, 0, 0)',
10
+ card: 'rgb(28, 28, 30)',
11
+ text: 'rgb(255, 255, 255)',
12
+ border: 'rgb(56, 56, 58)',
13
+ notification: 'rgb(255, 69, 58)',
14
+ },
15
+ fonts,
16
+ } as const satisfies LynxTheme;
@@ -0,0 +1,17 @@
1
+ import type { LynxTheme } from '../types';
2
+
3
+ import { fonts } from './fonts';
4
+
5
+ /** Same palette as `@react-navigation/native`, so themes port across renderers. */
6
+ export const LightTheme = {
7
+ dark: false,
8
+ colors: {
9
+ primary: 'rgb(0, 122, 255)',
10
+ background: 'rgb(242, 242, 247)',
11
+ card: 'rgb(255, 255, 255)',
12
+ text: 'rgb(0, 0, 0)',
13
+ border: 'rgb(198, 198, 200)',
14
+ notification: 'rgb(255, 59, 48)',
15
+ },
16
+ fonts,
17
+ } as const satisfies LynxTheme;
@@ -0,0 +1,25 @@
1
+ import type { LynxTheme } from '../types';
2
+
3
+ /**
4
+ * React Native picks per-platform families here (`System` on iOS,
5
+ * `sans-serif*` on Android). Lynx resolves the platform default when no family
6
+ * is named, so weights are all that need to differ.
7
+ */
8
+ export const fonts: LynxTheme['fonts'] = {
9
+ regular: {
10
+ fontFamily: '',
11
+ fontWeight: '400',
12
+ },
13
+ medium: {
14
+ fontFamily: '',
15
+ fontWeight: '500',
16
+ },
17
+ bold: {
18
+ fontFamily: '',
19
+ fontWeight: '600',
20
+ },
21
+ heavy: {
22
+ fontFamily: '',
23
+ fontWeight: '700',
24
+ },
25
+ };
package/src/types.ts ADDED
@@ -0,0 +1,42 @@
1
+ type FontStyle = {
2
+ fontFamily: string;
3
+ fontWeight:
4
+ | 'normal'
5
+ | 'bold'
6
+ | '100'
7
+ | '200'
8
+ | '300'
9
+ | '400'
10
+ | '500'
11
+ | '600'
12
+ | '700'
13
+ | '800'
14
+ | '900';
15
+ };
16
+
17
+ /**
18
+ * Core declares `Theme` as an empty interface for the platform layer to fill
19
+ * in, the same way `@react-navigation/native` does for React Native. Keeping
20
+ * the shape identical means themes carry over between the two unchanged.
21
+ */
22
+ export interface LynxTheme {
23
+ dark: boolean;
24
+ colors: {
25
+ primary: string;
26
+ background: string;
27
+ card: string;
28
+ text: string;
29
+ border: string;
30
+ notification: string;
31
+ };
32
+ fonts: {
33
+ regular: FontStyle;
34
+ medium: FontStyle;
35
+ bold: FontStyle;
36
+ heavy: FontStyle;
37
+ };
38
+ }
39
+
40
+ declare module '@react-navigation/core' {
41
+ interface Theme extends LynxTheme {}
42
+ }