@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.
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@react-navigation/lynx",
3
- "version": "0.0.0",
4
- "main": "index.js",
5
- "license": "MIT"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Lynx integration for React Navigation",
5
+ "keywords": [
6
+ "react",
7
+ "lynx",
8
+ "react-navigation"
9
+ ],
10
+ "homepage": "https://reactnavigation.org",
11
+ "bugs": {
12
+ "url": "https://github.com/react-navigation/react-navigation-lynx/issues"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/react-navigation/react-navigation-lynx.git",
17
+ "directory": "packages/lynx"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "type": "module",
23
+ "main": "./src/index.tsx",
24
+ "types": "./src/index.tsx",
25
+ "exports": {
26
+ ".": "./src/index.tsx",
27
+ "./stack": "./src/stack/index.tsx",
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "src"
32
+ ],
33
+ "dependencies": {
34
+ "@react-navigation/core": "^8.0.0-alpha.33"
35
+ },
36
+ "peerDependencies": {
37
+ "@lynx-js/react": "*",
38
+ "lynx-screens": "*"
39
+ },
40
+ "devDependencies": {
41
+ "@lynx-js/react": "^0.125.0",
42
+ "@lynx-js/testing-environment": "^0.3.3",
43
+ "@lynx-js/types": "^4.1.0",
44
+ "@types/react": "~19.2.17",
45
+ "jsdom": "^26.0.0",
46
+ "typescript": "^6.0.3",
47
+ "vitest": "^3.2.0",
48
+ "lynx-screens": "0.1.0"
49
+ },
50
+ "scripts": {
51
+ "typecheck": "tsc --noEmit",
52
+ "test": "vitest run"
53
+ }
54
+ }
@@ -0,0 +1,79 @@
1
+ import {
2
+ BaseNavigationContainer,
3
+ type NavigationContainerProps,
4
+ type NavigationContainerRef,
5
+ type NavigationState,
6
+ type ParamListBase,
7
+ type Theme,
8
+ ThemeProvider,
9
+ } from '@react-navigation/core';
10
+ import * as React from 'react';
11
+
12
+ import { LightTheme } from './theming/LightTheme';
13
+
14
+ export type NavigationContainerLynxProps<
15
+ ParamList extends {} = ParamListBase,
16
+ > = NavigationContainerProps & {
17
+ /**
18
+ * Theme handed to `useTheme` and to any navigator that reads colors.
19
+ */
20
+ theme?: Theme | undefined;
21
+ /**
22
+ * Rendered while persisted state is being restored.
23
+ */
24
+ fallback?: React.ReactNode | undefined;
25
+ ref?: React.Ref<NavigationContainerRef<ParamList>> | undefined;
26
+ };
27
+
28
+ /**
29
+ * The Lynx counterpart of `@react-navigation/native`'s `NavigationContainer`.
30
+ *
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.
44
+ */
45
+ export function NavigationContainer<ParamList extends {} = ParamListBase>({
46
+ theme = LightTheme,
47
+ fallback = null,
48
+ onStateChange,
49
+ ref,
50
+ ...rest
51
+ }: NavigationContainerLynxProps<ParamList>) {
52
+ const refContainer =
53
+ React.useRef<NavigationContainerRef<ParamListBase>>(null);
54
+
55
+ React.useImperativeHandle(
56
+ ref,
57
+ () => refContainer.current as NavigationContainerRef<ParamList>
58
+ );
59
+
60
+ const handleStateChange = (state: Readonly<NavigationState> | undefined) => {
61
+ onStateChange?.(state);
62
+ };
63
+
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
+ if (rest.children == null) {
68
+ return <ThemeProvider value={theme}>{fallback}</ThemeProvider>;
69
+ }
70
+
71
+ return (
72
+ <BaseNavigationContainer
73
+ {...rest}
74
+ theme={theme}
75
+ onStateChange={handleStateChange}
76
+ ref={refContainer}
77
+ />
78
+ );
79
+ }
@@ -0,0 +1,41 @@
1
+ import {
2
+ type RootParamList,
3
+ type StaticNavigation,
4
+ } from '@react-navigation/core';
5
+ import * as React from 'react';
6
+
7
+ import { NavigationContainer } from './NavigationContainer';
8
+
9
+ type Props<ParamList extends {}> = Omit<
10
+ React.ComponentProps<typeof NavigationContainer<ParamList>>,
11
+ 'children'
12
+ >;
13
+
14
+ /**
15
+ * Create a navigation component from a static navigation config, the same way
16
+ * `@react-navigation/native` does. The returned component wraps
17
+ * `NavigationContainer`.
18
+ *
19
+ * React Native's version also derives a linking config from the tree here.
20
+ * That is left out until the container can act on one - generating paths that
21
+ * nothing consumes would only look like deep linking works.
22
+ *
23
+ * @param tree Static navigation config.
24
+ * @returns Navigation component to use in your app.
25
+ */
26
+ export function createStaticNavigation(tree: StaticNavigation<any>) {
27
+ const Component = tree.getComponent();
28
+
29
+ function Navigation<ParamList extends {} = RootParamList>({
30
+ ref,
31
+ ...rest
32
+ }: Props<ParamList>) {
33
+ return (
34
+ <NavigationContainer {...rest} ref={ref}>
35
+ <Component />
36
+ </NavigationContainer>
37
+ );
38
+ }
39
+
40
+ return Navigation;
41
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,13 @@
1
+ // The platform layer, mirroring what `@react-navigation/native` does for
2
+ // React Native: it owns everything Lynx-specific that every navigator needs,
3
+ // and re-exports core so apps have a single import surface.
4
+ export { createStaticNavigation } from './createStaticNavigation';
5
+ export type { LynxTheme } from './types';
6
+ export {
7
+ NavigationContainer,
8
+ type NavigationContainerLynxProps,
9
+ } from './NavigationContainer';
10
+ export { DarkTheme } from './theming/DarkTheme';
11
+ export { LightTheme as DefaultTheme } from './theming/LightTheme';
12
+
13
+ export * from '@react-navigation/core';
@@ -0,0 +1,40 @@
1
+ // ReactLynx is Preact-based and does not implement every hook that
2
+ // `@react-navigation/core` imports from `react`. The Lynx build aliases
3
+ // `react` to this module (runtime alias in the bundler config, `paths` in
4
+ // tsconfig) so those imports link and both sides share one set of types.
5
+ //
6
+ // Tracking the gaps, counted against core's runtime source:
7
+ //
8
+ // - `use` (4 files): every call site passes a Context, never a promise, so
9
+ // `useContext` is a faithful stand-in today. Preact 11 ships the real one.
10
+ // - `startTransition` / `useTransition` (2 files): ReactLynx keeps these on
11
+ // its compat entry rather than the main one.
12
+ // - `useInsertionEffect` (15 files): no equivalent. `useLayoutEffect` is
13
+ // close but not equal - insertion effects run before layout effects, and
14
+ // layout effects run bottom-up, so a parent writing a ref in an insertion
15
+ // effect is guaranteed fresh when a child's layout effect reads it. Twelve
16
+ // of the fifteen are plain latest-ref writes where that gap is invisible;
17
+ // `useRegisterNavigator`, `usePreventRemove` and `PreventRemoveProvider`
18
+ // also register cleanups, and those are the ones to watch.
19
+ import * as ReactLynx from '@lynx-js/react';
20
+ import { useContext, useLayoutEffect } from '@lynx-js/react';
21
+ import { startTransition, useTransition } from '@lynx-js/react/compat';
22
+
23
+ export * from '@lynx-js/react';
24
+ export { startTransition, useTransition };
25
+
26
+ export const use = useContext;
27
+ export const useInsertionEffect = useLayoutEffect;
28
+
29
+ // `@lynx-js/react` declares no default export. Build one that keeps every
30
+ // generic signature intact, otherwise `import React from 'react'` degrades to
31
+ // `any` and callers lose inference (e.g. `React.useState` setter callbacks).
32
+ const React = {
33
+ ...ReactLynx,
34
+ use,
35
+ useInsertionEffect,
36
+ startTransition,
37
+ useTransition,
38
+ };
39
+
40
+ export default React;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Navigators
3
+ */
4
+ export {
5
+ createLynxStackNavigator,
6
+ createLynxStackScreen,
7
+ type LynxStackTypeBag,
8
+ } from './navigators/createLynxStackNavigator';
9
+
10
+ /**
11
+ * Types
12
+ */
13
+ export type {
14
+ LynxStackDescriptor,
15
+ LynxStackDescriptorMap,
16
+ LynxStackNavigationEventMap,
17
+ LynxStackNavigationHelpers,
18
+ LynxStackNavigationOptions,
19
+ LynxStackNavigationProp,
20
+ LynxStackNavigatorProps,
21
+ LynxStackPresentation,
22
+ LynxStackScreenProps,
23
+ } from './types';
@@ -0,0 +1,72 @@
1
+ import {
2
+ createNavigatorFactory,
3
+ createScreenFactory,
4
+ type NavigatorTypeBagBase,
5
+ type ParamListBase,
6
+ type StackActionHelpers,
7
+ type StackNavigationState,
8
+ StackRouter,
9
+ type StackRouterOptions,
10
+ useNavigationBuilder,
11
+ } from '@react-navigation/core';
12
+
13
+ import type {
14
+ LynxStackNavigationEventMap,
15
+ LynxStackNavigationOptions,
16
+ LynxStackNavigatorProps,
17
+ } from '../types';
18
+ import { LynxStackView } from '../views/LynxStackView';
19
+
20
+ function LynxStackNavigator({
21
+ initialRouteName,
22
+ routeNamesChangeBehavior,
23
+ children,
24
+ layout,
25
+ screenListeners,
26
+ screenOptions,
27
+ screenLayout,
28
+ router,
29
+ ...rest
30
+ }: LynxStackNavigatorProps) {
31
+ const { state, descriptors, navigation, NavigationContent } =
32
+ useNavigationBuilder<
33
+ StackNavigationState<ParamListBase>,
34
+ StackRouterOptions,
35
+ StackActionHelpers<ParamListBase>,
36
+ LynxStackNavigationOptions,
37
+ LynxStackNavigationEventMap
38
+ >(StackRouter, {
39
+ initialRouteName,
40
+ routeNamesChangeBehavior,
41
+ children,
42
+ layout,
43
+ screenListeners,
44
+ screenOptions,
45
+ screenLayout,
46
+ router,
47
+ });
48
+
49
+ return (
50
+ <NavigationContent>
51
+ <LynxStackView
52
+ {...rest}
53
+ state={state}
54
+ navigation={navigation}
55
+ descriptors={descriptors}
56
+ />
57
+ </NavigationContent>
58
+ );
59
+ }
60
+
61
+ export interface LynxStackTypeBag extends NavigatorTypeBagBase {
62
+ State: StackNavigationState<this['ParamList']>;
63
+ ScreenOptions: LynxStackNavigationOptions;
64
+ EventMap: LynxStackNavigationEventMap;
65
+ ActionHelpers: StackActionHelpers<this['ParamList']>;
66
+ Navigator: typeof LynxStackNavigator;
67
+ }
68
+
69
+ export const createLynxStackNavigator =
70
+ createNavigatorFactory<LynxStackTypeBag>(LynxStackNavigator);
71
+
72
+ export const createLynxStackScreen = createScreenFactory<LynxStackTypeBag>();
@@ -0,0 +1,66 @@
1
+ import type {
2
+ DefaultNavigatorOptions,
3
+ Descriptor,
4
+ NavigationHelpers,
5
+ NavigationProp,
6
+ ParamListBase,
7
+ RouteProp,
8
+ StackActionHelpers,
9
+ StackNavigationState,
10
+ StackRouterOptions,
11
+ } from '@react-navigation/core';
12
+ import type * as Lynx from '@lynx-js/types';
13
+
14
+ export type LynxStackPresentation = 'card' | 'formSheet';
15
+
16
+ export type LynxStackNavigationOptions = {
17
+ presentation?: LynxStackPresentation | undefined;
18
+ contentStyle?: Lynx.CSSProperties | undefined;
19
+ };
20
+
21
+ export type LynxStackNavigationEventMap = {
22
+ transitionStart: { data: { closing: boolean } };
23
+ transitionEnd: { data: { closing: boolean } };
24
+ };
25
+
26
+ export type LynxStackNavigationProp<
27
+ ParamList extends ParamListBase,
28
+ RouteName extends keyof ParamList = string,
29
+ > = NavigationProp<
30
+ ParamList,
31
+ RouteName,
32
+ StackNavigationState<ParamList>,
33
+ LynxStackNavigationOptions,
34
+ LynxStackNavigationEventMap,
35
+ StackActionHelpers<ParamList>
36
+ >;
37
+
38
+ export type LynxStackScreenProps<
39
+ ParamList extends ParamListBase,
40
+ RouteName extends keyof ParamList = string,
41
+ > = {
42
+ navigation: LynxStackNavigationProp<ParamList, RouteName>;
43
+ route: RouteProp<ParamList, RouteName>;
44
+ };
45
+
46
+ export type LynxStackNavigationHelpers = NavigationHelpers<
47
+ ParamListBase,
48
+ LynxStackNavigationEventMap
49
+ >;
50
+
51
+ export type LynxStackDescriptor = Descriptor<
52
+ LynxStackNavigationOptions,
53
+ LynxStackNavigationProp<ParamListBase>,
54
+ RouteProp<ParamListBase>
55
+ >;
56
+
57
+ export type LynxStackDescriptorMap = Record<string, LynxStackDescriptor>;
58
+
59
+ export type LynxStackNavigatorProps = DefaultNavigatorOptions<
60
+ ParamListBase,
61
+ StackNavigationState<ParamListBase>,
62
+ LynxStackNavigationOptions,
63
+ LynxStackNavigationEventMap,
64
+ LynxStackNavigationHelpers
65
+ > &
66
+ StackRouterOptions;
@@ -0,0 +1,102 @@
1
+ import { NavigationProvider, usePreventRemoveContext } from '@react-navigation/core';
2
+ import { StackScreenNativeComponent } from 'lynx-screens';
3
+
4
+ import type { LynxStackDescriptor, LynxStackNavigationHelpers } from '../types';
5
+
6
+ type Props = {
7
+ descriptor: LynxStackDescriptor;
8
+ navigation: LynxStackNavigationHelpers;
9
+ isFocused: boolean;
10
+ isBeforeLast: boolean;
11
+ isPopped: boolean;
12
+ isDetached: boolean;
13
+ onRemovePoppedRoute: (key: string) => void;
14
+ onNativeDismiss: () => void;
15
+ onNativeDismissPrevented: () => void;
16
+ };
17
+
18
+ export function CardScreen({
19
+ descriptor,
20
+ navigation,
21
+ isFocused,
22
+ isBeforeLast,
23
+ isPopped,
24
+ isDetached,
25
+ onRemovePoppedRoute,
26
+ onNativeDismiss,
27
+ onNativeDismissPrevented,
28
+ }: Props) {
29
+ const { preventedRoutes } = usePreventRemoveContext();
30
+
31
+ const { route, options } = descriptor;
32
+ const { contentStyle } = options;
33
+
34
+ // A screen is kept out of the native hierarchy while it animates out or
35
+ // while it sits above the focused index (preloaded / retained).
36
+ const activityMode = isPopped || isDetached ? 'detached' : 'attached';
37
+
38
+ // Prevention comes from `usePreventRemove` and nothing else. A static option
39
+ // would be a trap: the native side would block the gesture, then the
40
+ // `onNativeDismissPrevented` round-trip below would pop the route anyway,
41
+ // because only a `beforeRemove` listener can cancel that dispatch.
42
+ const isRemovePrevented = preventedRoutes[route.key]?.preventRemove;
43
+
44
+ // Only the focused screen, the one behind it (so a swipe back reveals fresh
45
+ // content) and detached screens stay live. `isBeforeLast` and `isFocused`
46
+ // are read here rather than in the parent so the reasoning stays with the
47
+ // component that acts on it.
48
+ const isLive = isFocused || isBeforeLast || isDetached;
49
+
50
+ return (
51
+ <StackScreenNativeComponent
52
+ screenKey={route.key}
53
+ activityMode={activityMode}
54
+ preventNativeDismiss={isRemovePrevented}
55
+ onWillAppear={() =>
56
+ navigation.emit({
57
+ type: 'transitionStart',
58
+ data: { closing: false },
59
+ target: route.key,
60
+ })
61
+ }
62
+ onDidAppear={() =>
63
+ navigation.emit({
64
+ type: 'transitionEnd',
65
+ data: { closing: false },
66
+ target: route.key,
67
+ })
68
+ }
69
+ onWillDisappear={() =>
70
+ navigation.emit({
71
+ type: 'transitionStart',
72
+ data: { closing: true },
73
+ target: route.key,
74
+ })
75
+ }
76
+ onDidDisappear={() =>
77
+ navigation.emit({
78
+ type: 'transitionEnd',
79
+ data: { closing: true },
80
+ target: route.key,
81
+ })
82
+ }
83
+ onDismiss={onRemovePoppedRoute}
84
+ onNativeDismiss={onNativeDismiss}
85
+ onNativeDismissPrevented={onNativeDismissPrevented}
86
+ >
87
+ <NavigationProvider navigation={descriptor.navigation} route={route}>
88
+ <view
89
+ style={{
90
+ display: 'flex',
91
+ flexDirection: 'column',
92
+ width: '100%',
93
+ height: '100%',
94
+ ...contentStyle,
95
+ }}
96
+ >
97
+ {isLive ? descriptor.render() : null}
98
+ </view>
99
+ </NavigationProvider>
100
+ </StackScreenNativeComponent>
101
+ );
102
+ }
@@ -0,0 +1,145 @@
1
+ import {
2
+ type ParamListBase,
3
+ StackActions,
4
+ type StackNavigationState,
5
+ } from '@react-navigation/core';
6
+ import { StackHostNativeComponent } from 'lynx-screens';
7
+ import type { Dispatch, ReactElement } from 'react';
8
+
9
+ import type {
10
+ LynxStackDescriptorMap,
11
+ LynxStackNavigationHelpers,
12
+ } from '../types';
13
+ import { CardScreen } from './CardScreen';
14
+ import {
15
+ type LynxStackViewState,
16
+ type LynxStackViewStateAction,
17
+ useViewState,
18
+ } from './LynxStackViewState';
19
+
20
+ type Props = {
21
+ state: StackNavigationState<ParamListBase>;
22
+ navigation: LynxStackNavigationHelpers;
23
+ descriptors: LynxStackDescriptorMap;
24
+ };
25
+
26
+ type ContentProps = Props &
27
+ Pick<LynxStackViewState, 'renderedRoutes' | 'poppedByKey'> & {
28
+ dispatch: Dispatch<LynxStackViewStateAction>;
29
+ };
30
+
31
+ function LynxStackViewContent({
32
+ state,
33
+ navigation,
34
+ descriptors,
35
+ renderedRoutes,
36
+ poppedByKey,
37
+ dispatch,
38
+ }: ContentProps) {
39
+ const routeIndexByKey = new Map(
40
+ state.routes.map((route, index) => [route.key, index])
41
+ );
42
+
43
+ const onRemovePoppedRoute = (key: string) => {
44
+ dispatch({ type: 'REMOVE_POPPED_ROUTE', key });
45
+ };
46
+
47
+ const onNativeDismiss = (key: string) => {
48
+ const currentState = navigation.getState();
49
+ const index = currentState.routes.findIndex((route) => route.key === key);
50
+
51
+ if (index === -1) {
52
+ return;
53
+ }
54
+
55
+ const dismissCount = currentState.index - index + 1;
56
+
57
+ if (dismissCount < 1) {
58
+ return;
59
+ }
60
+
61
+ // The native side has already taken these screens off the stack, so the
62
+ // reducer must not keep them rendered waiting for a pop animation.
63
+ dispatch({
64
+ type: 'ADD_NATIVELY_DISMISSED_ROUTES',
65
+ keys: currentState.routes
66
+ .slice(index, currentState.index + 1)
67
+ .map((route) => route.key),
68
+ });
69
+
70
+ navigation.dispatch({
71
+ ...StackActions.pop(dismissCount),
72
+ source: key,
73
+ target: currentState.key,
74
+ });
75
+ };
76
+
77
+ // A prevented dismiss still has to reach the router: that is what gives
78
+ // `usePreventRemove` its `beforeRemove` event to act on.
79
+ const onNativeDismissPrevented = (key: string) => {
80
+ const currentState = navigation.getState();
81
+
82
+ navigation.dispatch({
83
+ ...StackActions.pop(),
84
+ source: key,
85
+ target: currentState.key,
86
+ });
87
+ };
88
+
89
+ const cards = renderedRoutes.reduce<ReactElement[]>((result, route) => {
90
+ const index = routeIndexByKey.get(route.key);
91
+ const popped = poppedByKey.get(route.key);
92
+ const descriptor = descriptors[route.key] ?? popped?.descriptor;
93
+
94
+ if (descriptor == null) {
95
+ throw new Error(
96
+ `Couldn't find descriptor for route ${route.name} (${route.key}). This is likely a bug.`
97
+ );
98
+ }
99
+
100
+ const presentation = descriptor.options.presentation ?? 'card';
101
+
102
+ if (presentation !== 'card') {
103
+ throw new Error(
104
+ `The route '${route.name}' uses the '${presentation}' presentation, which the Lynx stack does not support yet. Only 'card' is available while form sheet support lands in lynx-screens.`
105
+ );
106
+ }
107
+
108
+ result.push(
109
+ <CardScreen
110
+ key={route.key}
111
+ descriptor={descriptor}
112
+ navigation={navigation}
113
+ isFocused={index === state.index}
114
+ isBeforeLast={index === state.index - 1}
115
+ isPopped={popped != null}
116
+ isDetached={index != null && index > state.index}
117
+ onRemovePoppedRoute={onRemovePoppedRoute}
118
+ onNativeDismiss={() => onNativeDismiss(route.key)}
119
+ onNativeDismissPrevented={() => onNativeDismissPrevented(route.key)}
120
+ />
121
+ );
122
+
123
+ return result;
124
+ }, []);
125
+
126
+ return <StackHostNativeComponent>{cards}</StackHostNativeComponent>;
127
+ }
128
+
129
+ export function LynxStackView({ state, navigation, descriptors }: Props) {
130
+ const [{ renderedRoutes, poppedByKey }, dispatch] = useViewState({
131
+ state,
132
+ descriptors,
133
+ });
134
+
135
+ return (
136
+ <LynxStackViewContent
137
+ state={state}
138
+ navigation={navigation}
139
+ descriptors={descriptors}
140
+ renderedRoutes={renderedRoutes}
141
+ poppedByKey={poppedByKey}
142
+ dispatch={dispatch}
143
+ />
144
+ );
145
+ }