From d966f3aaa3f3bd0d28d2bf608cc4df20db2e3b35 Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Wed, 16 Sep 2026 16:09:09 +0800 Subject: [PATCH] fix: keep a popped card rendered while it animates out --- .changeset/card-screen-align.md | 5 ++ packages/lynx/src/stack/types.ts | 14 ++++ packages/lynx/src/stack/views/CardContent.tsx | 59 +++++++++++++++ packages/lynx/src/stack/views/CardScreen.tsx | 74 ++++++++++++------- .../stack/views/__tests__/CardScreen.test.tsx | 63 ++++++++++++++++ packages/lynx/vitest.config.ts | 18 +++++ 6 files changed, 206 insertions(+), 27 deletions(-) create mode 100644 .changeset/card-screen-align.md create mode 100644 packages/lynx/src/stack/views/CardContent.tsx create mode 100644 packages/lynx/src/stack/views/__tests__/CardScreen.test.tsx diff --git a/.changeset/card-screen-align.md b/.changeset/card-screen-align.md new file mode 100644 index 0000000..f695e47 --- /dev/null +++ b/.changeset/card-screen-align.md @@ -0,0 +1,5 @@ +--- +'@react-navigation/lynx': patch +--- + +Keep a popped card screen rendered while it animates out, so going back with `navigation.goBack()` slides the screen away instead of dropping it at once. Inactive card screens now follow the new `inactiveBehavior` option, which defaults to `pause` and keeps them mounted. diff --git a/packages/lynx/src/stack/types.ts b/packages/lynx/src/stack/types.ts index 41dc532..41d9c74 100644 --- a/packages/lynx/src/stack/types.ts +++ b/packages/lynx/src/stack/types.ts @@ -21,6 +21,20 @@ export type LynxStackPresentation = 'card' | 'formSheet'; export type LynxStackNavigationOptions = { presentation?: LynxStackPresentation | undefined; contentStyle?: Lynx.CSSProperties | undefined; + /** + * What should happen when screens become inactive. + * - `pause`: Effects are cleaned up. + * - `unmount`: Screen is unmounted + * - `none`: Screen renders normally + * + * Defaults to `pause`. + * + * Preloaded screens won't be paused until after navigated to. + * This makes sure that effects are run to initialize the screen. + * + * Screens with nested navigators and last 2 screens won't be unmounted. + */ + inactiveBehavior?: 'pause' | 'unmount' | 'none' | undefined; /** * Heights the sheet can rest at, as fractions of the screen, or * `'fitToContents'` to measure the content. `formSheet` only, as is every diff --git a/packages/lynx/src/stack/views/CardContent.tsx b/packages/lynx/src/stack/views/CardContent.tsx new file mode 100644 index 0000000..9d0673f --- /dev/null +++ b/packages/lynx/src/stack/views/CardContent.tsx @@ -0,0 +1,59 @@ +// 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 { ReactNode } from '@lynx-js/react'; + +import type { LynxStackDescriptor } from '../types'; + +type Props = { + descriptor: LynxStackDescriptor; + activityMode: 'normal' | 'inert' | 'paused' | 'unmounted'; + backgroundColor: string; +}; + +export function CardContent({ + descriptor, + activityMode, + backgroundColor, +}: Props) { + const { options, render } = descriptor; + + const { contentStyle } = options; + + let contentElement: ReactNode; + + if (activityMode === 'unmounted') { + contentElement = null; + } else { + contentElement = ( + + {render()} + + ); + } + + return ( + + {contentElement} + + ); +} + +const styles = { + container: { + display: 'flex', + flexDirection: 'column', + width: '100%', + height: '100%', + }, + content: { + display: 'flex', + flexDirection: 'column', + width: '100%', + height: '100%', + }, +} as const; diff --git a/packages/lynx/src/stack/views/CardScreen.tsx b/packages/lynx/src/stack/views/CardScreen.tsx index d3f4ecd..e6b17a5 100644 --- a/packages/lynx/src/stack/views/CardScreen.tsx +++ b/packages/lynx/src/stack/views/CardScreen.tsx @@ -2,10 +2,18 @@ // 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 { NavigationProvider, usePreventRemoveContext } from '@react-navigation/core'; +import { + NavigationProvider, + usePreventRemoveContext, + useTheme, +} from '@react-navigation/core'; import { StackScreenNativeComponent } from 'lynx-screens'; -import type { LynxStackDescriptor, LynxStackNavigationHelpers } from '../types'; +import type { + LynxStackDescriptor, + LynxStackNavigationHelpers, +} from '../types'; +import { CardContent } from './CardContent'; type Props = { descriptor: LynxStackDescriptor; @@ -30,31 +38,49 @@ export function CardScreen({ onNativeDismiss, onNativeDismissPrevented, }: Props) { + const { colors } = useTheme(); const { preventedRoutes } = usePreventRemoveContext(); const { route, options } = descriptor; - const { contentStyle } = options; - // A screen is kept out of the native hierarchy while it animates out or - // while it sits above the focused index (preloaded / retained). - const activityMode = isPopped || isDetached ? 'detached' : 'attached'; + const { inactiveBehavior = 'pause' } = options; - // Prevention comes from `usePreventRemove` and nothing else. A static option - // would be a trap: the native side would block the gesture, then the - // `onNativeDismissPrevented` round-trip below would pop the route anyway, - // because only a `beforeRemove` listener can cancel that dispatch. const isRemovePrevented = preventedRoutes[route.key]?.preventRemove; + const hasNestedState = 'state' in route && route.state != null; - // Only the focused screen, the one behind it (so a swipe back reveals fresh - // content) and detached screens stay live. `isBeforeLast` and `isFocused` - // are read here rather than in the parent so the reasoning stays with the - // component that acts on it. - const isLive = isFocused || isBeforeLast || isDetached; + let activityMode: 'normal' | 'inert' | 'paused' | 'unmounted'; + + if (isPopped) { + // The screen is animating out, so don't let it handle any interaction + activityMode = 'inert'; + } else if ( + // Render focused screens normally + isFocused || + // Unpause previous screen so update isn't delayed for swipe back + isBeforeLast || + // Unpause preloaded and retained screens so updates are visible + // This lets effects on those screens run + isDetached + ) { + activityMode = 'normal'; + } else { + switch (inactiveBehavior) { + case 'none': + activityMode = 'normal'; + break; + case 'unmount': + activityMode = hasNestedState ? 'paused' : 'unmounted'; + break; + case 'pause': + activityMode = 'paused'; + break; + } + } return ( navigation.emit({ @@ -89,17 +115,11 @@ export function CardScreen({ onNativeDismissPrevented={onNativeDismissPrevented} > - - {isLive ? descriptor.render() : null} - + ); diff --git a/packages/lynx/src/stack/views/__tests__/CardScreen.test.tsx b/packages/lynx/src/stack/views/__tests__/CardScreen.test.tsx new file mode 100644 index 0000000..9893d81 --- /dev/null +++ b/packages/lynx/src/stack/views/__tests__/CardScreen.test.tsx @@ -0,0 +1,63 @@ +// 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, + type ParamListBase, +} from '@react-navigation/core'; +import { act, render } from '@lynx-js/react/testing-library'; + +import { NavigationContainer } from '../../../NavigationContainer'; +import type { LynxStackNavigationOptions } from '../../types'; +import { createLynxStackNavigator } from '../../navigators/createLynxStackNavigator'; + +const renderStack = (screenOptions: LynxStackNavigationOptions = {}) => { + const Stack = createLynxStackNavigator(); + const ref = createNavigationContainerRef(); + + const screen = (name: string) => () => {`content of ${name}`}; + + const result = render( + + + + + + + + ); + + return { ...result, ref }; +}; + +test('keeps a popped screen rendered until the native side dismisses it', () => { + const { container, ref } = renderStack(); + + act(() => ref.navigate('B')); + expect(container.textContent).toContain('content of B'); + + act(() => ref.goBack()); + + expect(container.textContent).toContain('content of B'); +}); + +test('keeps screens deeper than the one behind the top rendered by default', () => { + const { container, ref } = renderStack(); + + act(() => ref.navigate('B')); + act(() => ref.navigate('C')); + + expect(container.textContent).toContain('content of A'); +}); + +test('unmounts screens deeper than the one behind the top with inactiveBehavior unmount', () => { + const { container, ref } = renderStack({ inactiveBehavior: 'unmount' }); + + act(() => ref.navigate('B')); + act(() => ref.navigate('C')); + + expect(container.textContent).not.toContain('content of A'); + expect(container.textContent).toContain('content of B'); +}); diff --git a/packages/lynx/vitest.config.ts b/packages/lynx/vitest.config.ts index 199e63b..f299ba3 100644 --- a/packages/lynx/vitest.config.ts +++ b/packages/lynx/vitest.config.ts @@ -1,6 +1,14 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; + import { vitestTestingLibraryPlugin } from '@lynx-js/react/testing-library/plugins'; import { defineConfig } from 'vitest/config'; +const require = createRequire(import.meta.url); +const requireFromCore = createRequire( + require.resolve('@react-navigation/core'), +); + export default defineConfig({ plugins: [ vitestTestingLibraryPlugin({ @@ -9,5 +17,15 @@ export default defineConfig({ ], test: { name: '@react-navigation/lynx', + alias: [ + { find: /^react$/, replacement: require.resolve('@lynx-js/react/compat') }, + { + find: /^use-latest-callback$/, + replacement: path.join( + path.dirname(requireFromCore.resolve('use-latest-callback')), + '../../src/index.ts', + ), + }, + ], }, });