From f2be2b053af03ad7bcac5732dca4547d27cec80a Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Wed, 16 Sep 2026 16:13:07 +0800 Subject: [PATCH] fix: port the stack behaviors left out of native-stack --- .changeset/stack-view-omissions.md | 5 + .../createLynxStackNavigator.test.tsx | 147 ++++++++++++++++++ .../navigators/createLynxStackNavigator.tsx | 43 +++++ .../stack/utils/useDismissedRouteError.tsx | 34 ++++ .../lynx/src/stack/views/LynxStackView.tsx | 29 ++++ 5 files changed, 258 insertions(+) create mode 100644 .changeset/stack-view-omissions.md create mode 100644 packages/lynx/src/stack/navigators/__tests__/createLynxStackNavigator.test.tsx create mode 100644 packages/lynx/src/stack/utils/useDismissedRouteError.tsx 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(