Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stack-view-omissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@react-navigation/lynx': patch
---

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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Copyright 2026 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.

import { expect, test } from 'vitest';
import {
createNavigationContainerRef,
createNavigatorFactory,
type NavigationHelpers,
type ParamListBase,
StackActions,
type TabActionHelpers,
type TabNavigationState,
TabRouter,
type TabRouterOptions,
useNavigationBuilder,
} from '@react-navigation/core';
import type { ReactNode } from '@lynx-js/react';
import { act, render } from '@lynx-js/react/testing-library';

import { NavigationContainer } from '../../../NavigationContainer';
import { createLynxStackNavigator } from '../createLynxStackNavigator';

const screen = (name: string) => () => <text>{`content of ${name}`}</text>;

const nextFrame = () =>
new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));

test('throws when a route is pushed above a form sheet', () => {
const Stack = createLynxStackNavigator();
const ref = createNavigationContainerRef<ParamListBase>();

render(
<NavigationContainer ref={ref}>
<Stack.Navigator>
<Stack.Screen name='A' component={screen('A')} />
<Stack.Screen
name='Sheet'
component={screen('Sheet')}
options={{ presentation: 'formSheet' }}
/>
<Stack.Screen name='B' component={screen('B')} />
</Stack.Navigator>
</NavigationContainer>
);

act(() => ref.navigate('Sheet'));

expect(() => act(() => ref.navigate('B'))).toThrow(
/was pushed above the form sheet route 'Sheet'/
);
});

test('throws when a form sheet replaces another form sheet', () => {
const Stack = createLynxStackNavigator();
const ref = createNavigationContainerRef<ParamListBase>();

render(
<NavigationContainer ref={ref}>
<Stack.Navigator>
<Stack.Screen name='A' component={screen('A')} />
<Stack.Screen
name='First'
component={screen('First')}
options={{ presentation: 'formSheet' }}
/>
<Stack.Screen
name='Second'
component={screen('Second')}
options={{ presentation: 'formSheet' }}
/>
</Stack.Navigator>
</NavigationContainer>
);

act(() => ref.navigate('First'));

expect(() => act(() => ref.dispatch(StackActions.replace('Second')))).toThrow(
/cannot replace 'First'/
);
});

test('pops to top when the focused tab is pressed again', async () => {
let tabs:
| NavigationHelpers<
ParamListBase,
{ tabPress: { data: undefined; canPreventDefault: true } }
>
| undefined;

function TabNavigator({ children }: { children: ReactNode }) {
const { state, descriptors, navigation, render } = useNavigationBuilder<
TabNavigationState<ParamListBase>,
TabRouterOptions,
TabActionHelpers<ParamListBase>,
object,
{ tabPress: { data: undefined; canPreventDefault: true } }
>(TabRouter, { children });

tabs = navigation;

const focused = state.routes[state.index];

return render(
<view>{focused ? descriptors[focused.key]?.render() : null}</view>
);
}

const Tabs = createNavigatorFactory(TabNavigator)();
const Stack = createLynxStackNavigator();
const ref = createNavigationContainerRef<ParamListBase>();

function Feed() {
return (
<Stack.Navigator>
<Stack.Screen name='A' component={screen('A')} />
<Stack.Screen name='B' component={screen('B')} />
</Stack.Navigator>
);
}

render(
<NavigationContainer ref={ref}>
<Tabs.Navigator>
<Tabs.Screen name='Feed' component={Feed} />
</Tabs.Navigator>
</NavigationContainer>
);

act(() => ref.navigate('B'));
expect(ref.getCurrentRoute()?.name).toBe('B');

const feedKey = ref.getRootState()?.routes[0]?.key;

expect(feedKey).toBeDefined();

await act(async () => {
tabs?.emit({
type: 'tabPress',
target: feedKey as string,
canPreventDefault: true,
});
await nextFrame();
});

expect(ref.getCurrentRoute()?.name).toBe('A');
});
43 changes: 43 additions & 0 deletions packages/lynx/src/stack/navigators/createLynxStackNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@
import {
createNavigatorFactory,
createScreenFactory,
type EventArg,
NavigationMetaContext,
type NavigatorTypeBagBase,
type ParamListBase,
type StackActionHelpers,
StackActions,
type StackNavigationState,
StackRouter,
type StackRouterOptions,
useNavigationBuilder,
} from '@react-navigation/core';
import * as React from 'react';

import type {
LynxStackNavigationEventMap,
Expand Down Expand Up @@ -49,6 +53,45 @@ function LynxStackNavigator({
router,
});

const meta = React.use(NavigationMetaContext);

React.useEffect(() => {
if (meta && 'type' in meta && meta.type === 'native-tabs') {
return;
}

let handle: ReturnType<typeof requestAnimationFrame> | undefined;

// @ts-expect-error: there may not be a tab navigator in parent
const unsubscribe = navigation.addListener?.('tabPress', (e) => {
const isFocused = navigation.isFocused();

cancelAnimationFrame(handle);

// Run the operation in the next frame so we're sure all listeners have been run
// This is necessary to know if preventDefault() has been called
handle = requestAnimationFrame(() => {
const currentState = navigation.getState();

if (
isFocused &&
(currentState.index > 0 || currentState.routes[0]?.history?.length) &&
!(e as EventArg<'tabPress', true>).defaultPrevented
) {
navigation.dispatch({
...StackActions.popToTop(),
target: currentState.key,
});
}
});
});

return () => {
cancelAnimationFrame(handle);
unsubscribe?.();
};
}, [meta, navigation]);

return render(
<LynxStackView
{...rest}
Expand Down
34 changes: 34 additions & 0 deletions packages/lynx/src/stack/utils/useDismissedRouteError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright 2026 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.

import type {
ParamListBase,
StackNavigationState,
} from '@react-navigation/core';
import * as React from 'react';

export function useDismissedRouteError(
state: StackNavigationState<ParamListBase>
) {
const [nextDismissedKey, setNextDismissedKey] = React.useState<string | null>(
null
);

const dismissedRouteName = nextDismissedKey
? state.routes.find((route) => route.key === nextDismissedKey)?.name
: null;

React.useEffect(() => {
if (dismissedRouteName) {
const message =
`The screen '${dismissedRouteName}' was removed natively but didn't get removed from JS state. ` +
`This can happen if the action was prevented in a 'beforeRemove' listener, which is not fully supported in the Lynx stack.\n\n` +
`Consider using a 'usePreventRemove' hook instead.`;

console.error(message);
}
}, [dismissedRouteName]);

return { setNextDismissedKey };
}
29 changes: 29 additions & 0 deletions packages/lynx/src/stack/views/LynxStackView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
LynxStackDescriptorMap,
LynxStackNavigationHelpers,
} from '../types';
import { useDismissedRouteError } from '../utils/useDismissedRouteError';
import { CardScreen } from './CardScreen';
import { SheetScreen } from './SheetScreen';
import {
Expand Down Expand Up @@ -41,6 +42,8 @@ function LynxStackViewContent({
poppedByKey,
dispatch,
}: ContentProps) {
const { setNextDismissedKey } = useDismissedRouteError(state);

const routeIndexByKey = new Map(
state.routes.map((route, index) => [route.key, index])
);
Expand Down Expand Up @@ -86,6 +89,10 @@ function LynxStackViewContent({
source: key,
target: currentState.key,
});

if (markNativelyDismissed) {
setNextDismissedKey(key);
}
};

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

const routeAboveSheet =
index != null && index < state.index
? state.routes[index + 1]
: undefined;

if (routeAboveSheet != null) {
throw new Error(
`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.`
);
}

if (popped?.focusedReplacementKey != null) {
const replacementDescriptor =
descriptors[popped.focusedReplacementKey];

if (replacementDescriptor?.options.presentation === 'formSheet') {
throw new Error(
`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.`
);
}
}

sheets.push(
<SheetScreen
key={route.key}
Expand Down