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',
+ ),
+ },
+ ],
},
});