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/card-screen-align.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions packages/lynx/src/stack/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions packages/lynx/src/stack/views/CardContent.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<view
user-interaction-enabled={activityMode !== 'inert'}
style={styles.content}
>
{render()}
</view>
);
}

return (
<view style={{ ...styles.container, backgroundColor, ...contentStyle }}>
{contentElement}
</view>
);
}

const styles = {
container: {
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
},
content: {
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
},
} as const;
74 changes: 47 additions & 27 deletions packages/lynx/src/stack/views/CardScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 (
<StackScreenNativeComponent
screenKey={route.key}
activityMode={activityMode}
activityMode={isPopped || isDetached ? 'detached' : 'attached'}
preventNativeDismiss={isRemovePrevented}
onWillAppear={() =>
navigation.emit({
Expand Down Expand Up @@ -89,17 +115,11 @@ export function CardScreen({
onNativeDismissPrevented={onNativeDismissPrevented}
>
<NavigationProvider navigation={descriptor.navigation} route={route}>
<view
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
...contentStyle,
}}
>
{isLive ? descriptor.render() : null}
</view>
<CardContent
descriptor={descriptor}
activityMode={activityMode}
backgroundColor={colors.background}
/>
</NavigationProvider>
</StackScreenNativeComponent>
);
Expand Down
63 changes: 63 additions & 0 deletions packages/lynx/src/stack/views/__tests__/CardScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ParamListBase>();

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

const result = render(
<NavigationContainer ref={ref}>
<Stack.Navigator screenOptions={screenOptions}>
<Stack.Screen name='A' component={screen('A')} />
<Stack.Screen name='B' component={screen('B')} />
<Stack.Screen name='C' component={screen('C')} />
</Stack.Navigator>
</NavigationContainer>
);

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');
});
18 changes: 18 additions & 0 deletions packages/lynx/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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',
),
},
],
},
});
Loading