diff --git a/.changeset/stack-view-omissions.md b/.changeset/stack-view-omissions.md
new file mode 100644
index 0000000..0a12d8f
--- /dev/null
+++ b/.changeset/stack-view-omissions.md
@@ -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.
diff --git a/packages/lynx/src/stack/navigators/__tests__/createLynxStackNavigator.test.tsx b/packages/lynx/src/stack/navigators/__tests__/createLynxStackNavigator.test.tsx
new file mode 100644
index 0000000..f1df1eb
--- /dev/null
+++ b/packages/lynx/src/stack/navigators/__tests__/createLynxStackNavigator.test.tsx
@@ -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) => () => {`content of ${name}`};
+
+const nextFrame = () =>
+ new Promise((resolve) => requestAnimationFrame(() => resolve()));
+
+test('throws when a route is pushed above a form sheet', () => {
+ const Stack = createLynxStackNavigator();
+ const ref = createNavigationContainerRef();
+
+ render(
+
+
+
+
+
+
+
+ );
+
+ 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();
+
+ render(
+
+
+
+
+
+
+
+ );
+
+ 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,
+ TabRouterOptions,
+ TabActionHelpers,
+ object,
+ { tabPress: { data: undefined; canPreventDefault: true } }
+ >(TabRouter, { children });
+
+ tabs = navigation;
+
+ const focused = state.routes[state.index];
+
+ return render(
+ {focused ? descriptors[focused.key]?.render() : null}
+ );
+ }
+
+ const Tabs = createNavigatorFactory(TabNavigator)();
+ const Stack = createLynxStackNavigator();
+ const ref = createNavigationContainerRef();
+
+ function Feed() {
+ return (
+
+
+
+
+ );
+ }
+
+ render(
+
+
+
+
+
+ );
+
+ 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');
+});
diff --git a/packages/lynx/src/stack/navigators/createLynxStackNavigator.tsx b/packages/lynx/src/stack/navigators/createLynxStackNavigator.tsx
index 29eb39c..26a897c 100644
--- a/packages/lynx/src/stack/navigators/createLynxStackNavigator.tsx
+++ b/packages/lynx/src/stack/navigators/createLynxStackNavigator.tsx
@@ -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,
@@ -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 | 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(
+) {
+ const [nextDismissedKey, setNextDismissedKey] = React.useState(
+ 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 };
+}
diff --git a/packages/lynx/src/stack/views/LynxStackView.tsx b/packages/lynx/src/stack/views/LynxStackView.tsx
index 8598e0e..3dfb8a1 100644
--- a/packages/lynx/src/stack/views/LynxStackView.tsx
+++ b/packages/lynx/src/stack/views/LynxStackView.tsx
@@ -14,6 +14,7 @@ import type {
LynxStackDescriptorMap,
LynxStackNavigationHelpers,
} from '../types';
+import { useDismissedRouteError } from '../utils/useDismissedRouteError';
import { CardScreen } from './CardScreen';
import { SheetScreen } from './SheetScreen';
import {
@@ -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])
);
@@ -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
@@ -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(