diff --git a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt index 068b4e1d..59e02eba 100644 --- a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt +++ b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt @@ -33,6 +33,10 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.events.NativeGestureUtil @@ -99,6 +103,13 @@ class ComposePagerView(context: Context) : FrameLayout(context) { // resolves it instead of walking up to the ambient (and repeatedly // destroyed) Fragment view-tree Lifecycle - see the field comment above. setViewTreeLifecycleOwner(composeLifecycleOwner) + // Same for saved state. Compose would otherwise store the pager's + // rememberSaveable state (current page, page view state) in the host + // Fragment's SavedStateRegistry under a key derived from this view's + // id. It has none, so every pager shares one key and a remounted pager + // restores the previous pager's page instead of using initialPage. + // React owns the pager state, so the owner's registry is never saved. + setViewTreeSavedStateRegistryOwner(composeLifecycleOwner) setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnLifecycleDestroyed(composeLifecycleOwner) ) @@ -692,18 +703,29 @@ class ComposePagerView(context: Context) : FrameLayout(context) { // A stable Lifecycle that follows the currently attached host through // CREATED/STARTED/RESUMED, but only moves to DESTROYED when destroy() is // called explicitly. This lets it survive react-native-screens replacing the -// ambient Fragment view-tree owner (see the field comment above). -internal class ComposeViewLifecycleOwner : LifecycleOwner, LifecycleEventObserver { - private val registry = LifecycleRegistry(this).apply { - currentState = Lifecycle.State.CREATED - } +// ambient Fragment view-tree owner (see the field comment above). It also +// provides an empty SavedStateRegistry that is never saved, so nothing in the +// composition is persisted or restored. +internal class ComposeViewLifecycleOwner : + LifecycleOwner, SavedStateRegistryOwner, LifecycleEventObserver { + private val registry = LifecycleRegistry(this) + private val savedStateController = SavedStateRegistryController.create(this) private var hostLifecycle: Lifecycle? = null private var isAttached = false private var isDestroyed = false + init { + // Must run while the Lifecycle is still INITIALIZED. + savedStateController.performRestore(null) + registry.currentState = Lifecycle.State.CREATED + } + override val lifecycle: Lifecycle get() = registry + override val savedStateRegistry: SavedStateRegistry + get() = savedStateController.savedStateRegistry + fun attach(lifecycle: Lifecycle?) { if (isDestroyed) { return diff --git a/e2e/tests/issues/pr-1194-remount-initial-page.e2e.ts b/e2e/tests/issues/pr-1194-remount-initial-page.e2e.ts new file mode 100644 index 00000000..df7facac --- /dev/null +++ b/e2e/tests/issues/pr-1194-remount-initial-page.e2e.ts @@ -0,0 +1,30 @@ +import { freshAppBeforeEach, expect, test } from '../support/test.ts'; +import { openExample } from '../support/app.ts'; + +freshAppBeforeEach(); + +test( + 'a pager remounted after its screen was covered starts on its own initialPage', + { tags: ['regression', 'pr-1194'] }, + async ({ app, screen }) => { + await openExample(app, screen, 'PR #1194 Remount InitialPage Repro'); + await expect(screen.getByTestId('pr-1194-page-0')).toBeVisible(); + + await screen.getByTestId('pr-1194-go-to-2').tap(); + await expect(screen.getByTestId('pr-1194-page-2')).toBeVisible(); + + // Covering the screen makes react-native-screens save its Fragment view + // state, which used to include the pager's current page. + await screen.getByTestId('pr-1194-open-cover').tap(); + await expect(screen.getByText('Cover screen')).toBeVisible(); + await screen.getByTestId('pr-1194-close-cover').tap(); + await expect(screen.getByTestId('pr-1194-page-2')).toBeVisible(); + + await screen.getByTestId('pr-1194-remount').tap(); + await expect(screen.getByTestId('pr-1194-label')).toHaveText( + 'Mount 1, initialPage 1' + ); + await expect(screen.getByTestId('pr-1194-page-1')).toBeVisible(); + await expect(screen.getByTestId('pr-1194-page-2')).toBeHidden(); + } +); diff --git a/example/src/App.tsx b/example/src/App.tsx index c86465f1..15a173df 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -55,6 +55,10 @@ import { Issue1154PageChangeFocusRepro } from './gh-issues/Issue1154PageChangeFo import { Issue1156NestedHorizontalScrollRepro } from './gh-issues/Issue1156NestedHorizontalScrollRepro'; import { Issue424SwipeCancelsPressRepro } from './gh-issues/Issue424SwipeCancelsPressRepro'; import { Issue965PagesBeforeCurrentRepro } from './gh-issues/Issue965PagesBeforeCurrentRepro'; +import { + PR1194RemountInitialPageCoverScreen, + PR1194RemountInitialPageRepro, +} from './gh-issues/PR1194RemountInitialPageRepro'; import { colors, radius } from './theme'; function BasicPagerViewExampleScreen() { @@ -179,6 +183,10 @@ const sections: { title: string; data: Example[] }[] = [ component: Issue965PagesBeforeCurrentRepro, name: 'Issue #965 Pages Before Current Repro', }, + { + component: PR1194RemountInitialPageRepro, + name: 'PR #1194 Remount InitialPage Repro', + }, ], }, ]; @@ -308,6 +316,10 @@ export function Navigation() { name="Issue #1142 Pager List" component={Issue1142PagerListScreen} /> + diff --git a/example/src/gh-issues/PR1194RemountInitialPageRepro.tsx b/example/src/gh-issues/PR1194RemountInitialPageRepro.tsx new file mode 100644 index 00000000..19d741f6 --- /dev/null +++ b/example/src/gh-issues/PR1194RemountInitialPageRepro.tsx @@ -0,0 +1,105 @@ +/** + * Repro for #1194: the Android pager restored a previous pager's page + * instead of using initialPage. The pager's ComposeView saved its + * rememberSaveable state in the host Fragment's SavedStateRegistry under a key + * shared by every pager. Covering the screen with a native-stack push saves + * that state, and the next pager mounted on the screen restored it. + * + * Steps: go to page 2, open the cover screen, come back, remount. The remounted + * pager must start on page 1, its initialPage. + */ +import * as React from 'react'; +import { Button, SafeAreaView, StyleSheet, Text, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; +import PagerView from 'react-native-pager-view'; + +export type PR1194RemountInitialPageParamList = { + 'PR #1194 Remount InitialPage Repro': undefined; + 'PR #1194 Remount InitialPage Cover': undefined; +}; + +const PAGES = ['Page 0', 'Page 1', 'Page 2']; +const PAGE_COLORS = ['#fde2e2', '#e2fde6', '#e2e8fd']; + +export function PR1194RemountInitialPageRepro() { + const pagerRef = React.useRef(null); + const [mount, setMount] = React.useState({ key: 0, initialPage: 0 }); + const navigation = + useNavigation< + NativeStackNavigationProp< + PR1194RemountInitialPageParamList, + 'PR #1194 Remount InitialPage Repro' + > + >(); + + return ( + + + + Mount {mount.key}, initialPage {mount.initialPage} + +