Skip to content

Commit ccc1b93

Browse files
committed
fix: port the stack behaviors left out of native-stack
1 parent 13bb91c commit ccc1b93

5 files changed

Lines changed: 288 additions & 0 deletions

File tree

‎.changeset/stack-view-omissions.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@react-navigation/lynx': patch
3+
---
4+
5+
Bring back three things the Lynx stack left out of `@react-navigation/native-stack`: pressing the focused tab of a parent tab navigator pops the stack to its top, a route pushed above a `formSheet` or a sheet replacing another sheet throws a descriptive error, and a screen removed natively but kept in JS state logs an error in development.
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Copyright 2026 The Lynx Authors. All rights reserved.
2+
// Licensed under the Apache License Version 2.0 that can be found in the
3+
// LICENSE file in the root directory of this source tree.
4+
5+
import { expect, test } from 'vitest';
6+
import {
7+
createNavigationContainerRef,
8+
createNavigatorFactory,
9+
type NavigationHelpers,
10+
type ParamListBase,
11+
StackActions,
12+
type TabActionHelpers,
13+
type TabNavigationState,
14+
TabRouter,
15+
type TabRouterOptions,
16+
useNavigationBuilder,
17+
} from '@react-navigation/core';
18+
import type { ReactNode } from '@lynx-js/react';
19+
import { act, render } from '@lynx-js/react/testing-library';
20+
21+
import { NavigationContainer } from '../../../NavigationContainer';
22+
import { createLynxStackNavigator } from '../createLynxStackNavigator';
23+
24+
const screen = (name: string) => () => <text>{`content of ${name}`}</text>;
25+
26+
const nextFrame = () =>
27+
new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
28+
29+
test('throws when a route is pushed above a form sheet', () => {
30+
const Stack = createLynxStackNavigator();
31+
const ref = createNavigationContainerRef<ParamListBase>();
32+
33+
render(
34+
<NavigationContainer ref={ref}>
35+
<Stack.Navigator>
36+
<Stack.Screen name='A' component={screen('A')} />
37+
<Stack.Screen
38+
name='Sheet'
39+
component={screen('Sheet')}
40+
options={{ presentation: 'formSheet' }}
41+
/>
42+
<Stack.Screen name='B' component={screen('B')} />
43+
</Stack.Navigator>
44+
</NavigationContainer>
45+
);
46+
47+
act(() => ref.navigate('Sheet'));
48+
49+
expect(() => act(() => ref.navigate('B'))).toThrow(
50+
/was pushed above the form sheet route 'Sheet'/
51+
);
52+
});
53+
54+
test('throws when a form sheet replaces another form sheet', () => {
55+
const Stack = createLynxStackNavigator();
56+
const ref = createNavigationContainerRef<ParamListBase>();
57+
58+
render(
59+
<NavigationContainer ref={ref}>
60+
<Stack.Navigator>
61+
<Stack.Screen name='A' component={screen('A')} />
62+
<Stack.Screen
63+
name='First'
64+
component={screen('First')}
65+
options={{ presentation: 'formSheet' }}
66+
/>
67+
<Stack.Screen
68+
name='Second'
69+
component={screen('Second')}
70+
options={{ presentation: 'formSheet' }}
71+
/>
72+
</Stack.Navigator>
73+
</NavigationContainer>
74+
);
75+
76+
act(() => ref.navigate('First'));
77+
78+
expect(() => act(() => ref.dispatch(StackActions.replace('Second')))).toThrow(
79+
/cannot replace 'First'/
80+
);
81+
});
82+
83+
test('pops to top when the focused tab is pressed again', async () => {
84+
let tabs:
85+
| NavigationHelpers<
86+
ParamListBase,
87+
{ tabPress: { data: undefined; canPreventDefault: true } }
88+
>
89+
| undefined;
90+
91+
function TabNavigator({ children }: { children: ReactNode }) {
92+
const { state, descriptors, navigation, render } = useNavigationBuilder<
93+
TabNavigationState<ParamListBase>,
94+
TabRouterOptions,
95+
TabActionHelpers<ParamListBase>,
96+
object,
97+
{ tabPress: { data: undefined; canPreventDefault: true } }
98+
>(TabRouter, { children });
99+
100+
tabs = navigation;
101+
102+
const focused = state.routes[state.index];
103+
104+
return render(
105+
<view>{focused ? descriptors[focused.key]?.render() : null}</view>
106+
);
107+
}
108+
109+
const Tabs = createNavigatorFactory(TabNavigator)();
110+
const Stack = createLynxStackNavigator();
111+
const ref = createNavigationContainerRef<ParamListBase>();
112+
113+
function Feed() {
114+
return (
115+
<Stack.Navigator>
116+
<Stack.Screen name='A' component={screen('A')} />
117+
<Stack.Screen name='B' component={screen('B')} />
118+
</Stack.Navigator>
119+
);
120+
}
121+
122+
render(
123+
<NavigationContainer ref={ref}>
124+
<Tabs.Navigator>
125+
<Tabs.Screen name='Feed' component={Feed} />
126+
</Tabs.Navigator>
127+
</NavigationContainer>
128+
);
129+
130+
act(() => ref.navigate('B'));
131+
expect(ref.getCurrentRoute()?.name).toBe('B');
132+
133+
const feedKey = ref.getRootState()?.routes[0]?.key;
134+
135+
expect(feedKey).toBeDefined();
136+
137+
await act(async () => {
138+
tabs?.emit({
139+
type: 'tabPress',
140+
target: feedKey as string,
141+
canPreventDefault: true,
142+
});
143+
await nextFrame();
144+
});
145+
146+
expect(ref.getCurrentRoute()?.name).toBe('A');
147+
});

‎packages/lynx/src/stack/navigators/createLynxStackNavigator.tsx‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@
55
import {
66
createNavigatorFactory,
77
createScreenFactory,
8+
type EventArg,
9+
NavigationMetaContext,
810
type NavigatorTypeBagBase,
911
type ParamListBase,
1012
type StackActionHelpers,
13+
StackActions,
1114
type StackNavigationState,
1215
StackRouter,
1316
type StackRouterOptions,
1417
useNavigationBuilder,
1518
} from '@react-navigation/core';
19+
import * as React from 'react';
1620

1721
import type {
1822
LynxStackNavigationEventMap,
@@ -49,6 +53,45 @@ function LynxStackNavigator({
4953
router,
5054
});
5155

56+
const meta = React.use(NavigationMetaContext);
57+
58+
React.useEffect(() => {
59+
if (meta && 'type' in meta && meta.type === 'native-tabs') {
60+
return;
61+
}
62+
63+
let handle: ReturnType<typeof requestAnimationFrame> | undefined;
64+
65+
// @ts-expect-error: there may not be a tab navigator in parent
66+
const unsubscribe = navigation.addListener?.('tabPress', (e) => {
67+
const isFocused = navigation.isFocused();
68+
69+
cancelAnimationFrame(handle);
70+
71+
// Run the operation in the next frame so we're sure all listeners have been run
72+
// This is necessary to know if preventDefault() has been called
73+
handle = requestAnimationFrame(() => {
74+
const currentState = navigation.getState();
75+
76+
if (
77+
isFocused &&
78+
(currentState.index > 0 || currentState.routes[0]?.history?.length) &&
79+
!(e as EventArg<'tabPress', true>).defaultPrevented
80+
) {
81+
navigation.dispatch({
82+
...StackActions.popToTop(),
83+
target: currentState.key,
84+
});
85+
}
86+
});
87+
});
88+
89+
return () => {
90+
cancelAnimationFrame(handle);
91+
unsubscribe?.();
92+
};
93+
}, [meta, navigation]);
94+
5295
return render(
5396
<LynxStackView
5497
{...rest}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright 2026 The Lynx Authors. All rights reserved.
2+
// Licensed under the Apache License Version 2.0 that can be found in the
3+
// LICENSE file in the root directory of this source tree.
4+
5+
/**
6+
* @license
7+
MIT License
8+
9+
Copyright (c) 2017 React Navigation Contributors
10+
11+
Permission is hereby granted, free of charge, to any person obtaining a copy
12+
of this software and associated documentation files (the "Software"), to deal
13+
in the Software without restriction, including without limitation the rights
14+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15+
copies of the Software, and to permit persons to whom the Software is
16+
furnished to do so, subject to the following conditions:
17+
18+
The above copyright notice and this permission notice shall be included in all
19+
copies or substantial portions of the Software.
20+
21+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27+
SOFTWARE.
28+
*/
29+
30+
// Ported from react-navigation's native stack. The advice at the end of the
31+
// message drops `headerBackButtonMenuEnabled`, a header option this package
32+
// does not have.
33+
//
34+
// packages/native-stack/src/utils/useDismissedRouteError.tsx @ 7b32fd92d
35+
import type {
36+
ParamListBase,
37+
StackNavigationState,
38+
} from '@react-navigation/core';
39+
import * as React from 'react';
40+
41+
export function useDismissedRouteError(
42+
state: StackNavigationState<ParamListBase>
43+
) {
44+
const [nextDismissedKey, setNextDismissedKey] = React.useState<string | null>(
45+
null
46+
);
47+
48+
const dismissedRouteName = nextDismissedKey
49+
? state.routes.find((route) => route.key === nextDismissedKey)?.name
50+
: null;
51+
52+
React.useEffect(() => {
53+
if (dismissedRouteName) {
54+
const message =
55+
`The screen '${dismissedRouteName}' was removed natively but didn't get removed from JS state. ` +
56+
`This can happen if the action was prevented in a 'beforeRemove' listener, which is not fully supported in the Lynx stack.\n\n` +
57+
`Consider using a 'usePreventRemove' hook instead.`;
58+
59+
console.error(message);
60+
}
61+
}, [dismissedRouteName]);
62+
63+
return { setNextDismissedKey };
64+
}

‎packages/lynx/src/stack/views/LynxStackView.tsx‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
LynxStackDescriptorMap,
1515
LynxStackNavigationHelpers,
1616
} from '../types';
17+
import { useDismissedRouteError } from '../utils/useDismissedRouteError';
1718
import { CardScreen } from './CardScreen';
1819
import { SheetScreen } from './SheetScreen';
1920
import {
@@ -41,6 +42,8 @@ function LynxStackViewContent({
4142
poppedByKey,
4243
dispatch,
4344
}: ContentProps) {
45+
const { setNextDismissedKey } = useDismissedRouteError(state);
46+
4447
const routeIndexByKey = new Map(
4548
state.routes.map((route, index) => [route.key, index])
4649
);
@@ -86,6 +89,10 @@ function LynxStackViewContent({
8689
source: key,
8790
target: currentState.key,
8891
});
92+
93+
if (markNativelyDismissed) {
94+
setNextDismissedKey(key);
95+
}
8996
};
9097

9198
// A prevented dismiss still has to reach the router: that is what gives
@@ -131,6 +138,28 @@ function LynxStackViewContent({
131138
);
132139
}
133140

141+
const routeAboveSheet =
142+
index != null && index < state.index
143+
? state.routes[index + 1]
144+
: undefined;
145+
146+
if (routeAboveSheet != null) {
147+
throw new Error(
148+
`The route '${routeAboveSheet.name}' was pushed above the form sheet route '${route.name}' in the same Lynx stack. A form sheet does not create a nested stack automatically. Render a nested navigator inside '${route.name}' and push '${routeAboveSheet.name}' on that nested navigator instead.`
149+
);
150+
}
151+
152+
if (popped?.focusedReplacementKey != null) {
153+
const replacementDescriptor =
154+
descriptors[popped.focusedReplacementKey];
155+
156+
if (replacementDescriptor?.options.presentation === 'formSheet') {
157+
throw new Error(
158+
`The form sheet route '${replacementDescriptor.route.name}' cannot replace '${route.name}' in the same Lynx stack. Wait for the previous sheet to close before presenting another sheet.`
159+
);
160+
}
161+
}
162+
134163
sheets.push(
135164
<SheetScreen
136165
key={route.key}

0 commit comments

Comments
 (0)