diff --git a/.gitignore b/.gitignore
index c979e30d..b9296349 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,6 +28,7 @@ DerivedData
*.ipa
*.xcuserstate
project.xcworkspace
+**/.xcode.env
**/.xcode.env.local
# Android/IJ
@@ -82,3 +83,4 @@ example/ios/PagerViewExample.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
!.yarn/releases
!.yarn/sdks
!.yarn/versions
+.agent-device
diff --git a/.maestro/issues/issue_1083_modal_set_page_repro.yaml b/.maestro/issues/issue_1083_modal_set_page_repro.yaml
new file mode 100644
index 00000000..314ca461
--- /dev/null
+++ b/.maestro/issues/issue_1083_modal_set_page_repro.yaml
@@ -0,0 +1,58 @@
+appId: com.pagerviewexample
+tags:
+ - ios
+ - regression
+---
+- runFlow: ../setup/issue_1083_modal_set_page_repro_setup.yaml
+
+# This deliberately invokes setPage while the pager is obscured by a native
+# stack modal. Before the fix, React state became 0 while SwiftUI showed Page 1.
+- tapOn:
+ id: 'issue-1083-open-modal'
+
+- extendedWaitUntil:
+ visible: 'Modal screen'
+ timeout: 10000
+
+- tapOn:
+ id: 'issue-1083-submit'
+
+- extendedWaitUntil:
+ visible: 'Last requested page: 1'
+ timeout: 10000
+
+- extendedWaitUntil:
+ visible:
+ id: 'issue-1083-page-1'
+ timeout: 10000
+
+- tapOn:
+ id: 'issue-1083-advance-directly'
+
+- extendedWaitUntil:
+ visible: 'Last requested page: 2'
+ timeout: 10000
+
+- extendedWaitUntil:
+ visible:
+ id: 'issue-1083-page-2'
+ timeout: 10000
+
+- tapOn:
+ id: 'issue-1083-open-modal'
+
+- extendedWaitUntil:
+ visible: 'Modal screen'
+ timeout: 10000
+
+- tapOn:
+ id: 'issue-1083-submit'
+
+- extendedWaitUntil:
+ visible: 'Last requested page: 0'
+ timeout: 10000
+
+- extendedWaitUntil:
+ visible:
+ id: 'issue-1083-page-0'
+ timeout: 10000
diff --git a/.maestro/tests/issue_1098_nested_pager_repro.yaml b/.maestro/issues/issue_1098_nested_pager_repro.yaml
similarity index 100%
rename from .maestro/tests/issue_1098_nested_pager_repro.yaml
rename to .maestro/issues/issue_1098_nested_pager_repro.yaml
diff --git a/.maestro/setup/issue_1083_modal_set_page_repro_setup.yaml b/.maestro/setup/issue_1083_modal_set_page_repro_setup.yaml
new file mode 100644
index 00000000..fb1afa10
--- /dev/null
+++ b/.maestro/setup/issue_1083_modal_set_page_repro_setup.yaml
@@ -0,0 +1,20 @@
+appId: com.pagerviewexample
+---
+- launchApp
+
+- scrollUntilVisible:
+ element:
+ id: 'Issue #1083 Modal SetPage Repro'
+ direction: DOWN
+
+- tapOn:
+ id: 'Issue #1083 Modal SetPage Repro'
+
+- extendedWaitUntil:
+ visible:
+ id: 'issue-1083-requested-page'
+ timeout: 10000
+
+- assertVisible: 'Last requested page: 0'
+- assertVisible:
+ id: 'issue-1083-page-0'
diff --git a/.maestro/tests/material_top_bar_example.yaml b/.maestro/tests/material_top_bar_example.yaml
index 2f441a6a..4813f4c4 100644
--- a/.maestro/tests/material_top_bar_example.yaml
+++ b/.maestro/tests/material_top_bar_example.yaml
@@ -34,7 +34,8 @@ appId: com.pagerviewexample
id: 'material-top-bar-detail-screen'
timeout: 5000
-- pressKey: Back
+- tapOn:
+ id: 'material-top-bar-back-button'
- extendedWaitUntil:
visible:
diff --git a/example/.gitignore b/example/.gitignore
index d0ece84a..fdb76b17 100644
--- a/example/.gitignore
+++ b/example/.gitignore
@@ -6,6 +6,7 @@
.gradle/
.idea/
.vs/
+**/.xcode.env
**/.xcode.env.local
Pods/
build/
diff --git a/example/ios/.xcode.env b/example/ios/.xcode.env
deleted file mode 100644
index 3d5782c7..00000000
--- a/example/ios/.xcode.env
+++ /dev/null
@@ -1,11 +0,0 @@
-# This `.xcode.env` file is versioned and is used to source the environment
-# used when running script phases inside Xcode.
-# To customize your local environment, you can create an `.xcode.env.local`
-# file that is not versioned.
-
-# NODE_BINARY variable contains the PATH to the node executable.
-#
-# Customize the NODE_BINARY variable here.
-# For example, to use nvm with brew, add the following line
-# . "$(brew --prefix nvm)/nvm.sh" --no-use
-export NODE_BINARY=$(command -v node)
diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock
index ca19358f..285d3356 100644
--- a/example/ios/Podfile.lock
+++ b/example/ios/Podfile.lock
@@ -1402,7 +1402,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- - react-native-pager-view (9.0.0):
+ - react-native-pager-view (9.0.3):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2496,7 +2496,7 @@ SPEC CHECKSUMS:
React-Mapbuffer: 1aa9126122d4247ffc24bf9d28d50ce923499a71
React-microtasksnativemodule: d86581169e9bb5bb6f5fc3c5052f890016c1bf21
React-mutationobservernativemodule: 9a0c4e866f1ef2a57acebc902ebacbdf469ae741
- react-native-pager-view: 91a6d37f552b9c47445cc94792ad75cfa08487b2
+ react-native-pager-view: 27f9cb535d59648492018892b7e3bffe0b511807
react-native-safe-area-context: c1eb308f4b36372a4de4b3bdaa8ed695ec3dd461
React-NativeModulesApple: cc6ec4767844d610e92cc358bd3ea34937438d56
React-networking: a8ce15641ed7775d5b54a9d0d32defc367c2216e
diff --git a/example/package.json b/example/package.json
index 1703d5c7..064bcd7c 100644
--- a/example/package.json
+++ b/example/package.json
@@ -8,8 +8,8 @@
"test": "jest",
"lint": "eslint .",
"mkdist": "node -e \"require('node:fs').mkdirSync('dist', { recursive: true, mode: 0o755 })\"",
- "android": "react-native run-android --appId com.pagerviewexample --list-devices --active-arch-only",
- "ios": "react-native run-ios --list-devices",
+ "android": "react-native run-android --appId com.pagerviewexample",
+ "ios": "react-native run-ios",
"visionos": "react-native run-visionos",
"build:android": "bun mkdist && react-native bundle --entry-file index.js --platform android --dev true --bundle-output dist/main.android.jsbundle --assets-dest dist/res",
"build:ios": "bun mkdist && react-native bundle --entry-file index.js --platform ios --dev false --bundle-output dist/main.ios.jsbundle --assets-dest dist",
diff --git a/example/src/App.tsx b/example/src/App.tsx
index 87920525..88b31628 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -34,8 +34,12 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { PagerHookExample } from './PagerHookExample';
import { NestedHorizontalScrollViewExample } from './NestedHorizontalScrollViewExample';
-import { Issue1098NestedPagerRepro } from './Issue1098NestedPagerRepro';
-import { Issue1099SafeAreaRepro } from './Issue1099SafeAreaRepro';
+import { Issue1098NestedPagerRepro } from './gh-issues/Issue1098NestedPagerRepro';
+import { Issue1099SafeAreaRepro } from './gh-issues/Issue1099SafeAreaRepro';
+import {
+ Issue1083ModalSetPageExample,
+ ModalSetPageModalScreen,
+} from './gh-issues/Issue1083ModalSetPageExample';
function BasicPagerViewExampleScreen() {
return ;
@@ -108,6 +112,10 @@ const additionalExamples: Example[] = [
];
const ghIssues: Example[] = [
+ {
+ component: Issue1083ModalSetPageExample,
+ name: 'Issue #1083 Modal SetPage Repro',
+ },
{
component: Issue1098NestedPagerRepro,
name: 'Issue #1098 Nested Pager Repro',
@@ -249,6 +257,14 @@ export function Navigation() {
component={example.component}
/>
))}
+
diff --git a/example/src/MaterialTopTabExample.tsx b/example/src/MaterialTopTabExample.tsx
index d6cb7b25..79715d7c 100644
--- a/example/src/MaterialTopTabExample.tsx
+++ b/example/src/MaterialTopTabExample.tsx
@@ -95,6 +95,17 @@ const DetailScreen = () => (
);
+const detailScreenOptions = ({ navigation }: any) => ({
+ headerBackVisible: false,
+ headerLeft: () => (
+
+ ),
+});
+
export function MaterialTopBarExample() {
const { Screen, Navigator } = createNativeStackNavigator();
const [isSignedIn, setIsSignedIn] = useState(false);
@@ -118,7 +129,11 @@ export function MaterialTopBarExample() {
)}
)}
-
+
);
}
diff --git a/example/src/gh-issues/Issue1083ModalSetPageExample.tsx b/example/src/gh-issues/Issue1083ModalSetPageExample.tsx
new file mode 100644
index 00000000..67cbe8e2
--- /dev/null
+++ b/example/src/gh-issues/Issue1083ModalSetPageExample.tsx
@@ -0,0 +1,111 @@
+/**
+ * Repro for #1083: setPage lands on the wrong index when called while the
+ * pager is obscured by a presented native-stack modal on iOS.
+ */
+import * as React from 'react';
+import { Button, SafeAreaView, StyleSheet, Text, View } from 'react-native';
+import {
+ useNavigation,
+ useRoute,
+ type RouteProp,
+} from '@react-navigation/native';
+import { type NativeStackNavigationProp } from '@react-navigation/native-stack';
+import PagerView from 'react-native-pager-view';
+
+export type ModalSetPageParamList = {
+ 'Issue #1083 Modal SetPage Repro': undefined;
+ 'ModalSetPageModal': { onSubmit: () => void };
+};
+
+const PAGES = ['Page 0', 'Page 1', 'Page 2'];
+const PAGE_COLORS = ['#fde2e2', '#e2fde6', '#e2e8fd'];
+
+export function Issue1083ModalSetPageExample() {
+ const pagerRef = React.useRef(null);
+ const [currentPage, setCurrentPage] = React.useState(0);
+ const navigation =
+ useNavigation<
+ NativeStackNavigationProp<
+ ModalSetPageParamList,
+ 'Issue #1083 Modal SetPage Repro'
+ >
+ >();
+
+ const advancePager = React.useCallback(() => {
+ const next = (currentPage + 1) % PAGES.length;
+ console.log('[setPage]', next);
+ pagerRef.current?.setPage(next);
+ setCurrentPage(next);
+ }, [currentPage]);
+
+ const openModal = React.useCallback(() => {
+ navigation.navigate('ModalSetPageModal', { onSubmit: advancePager });
+ }, [advancePager, navigation]);
+
+ return (
+
+
+
+ Last requested page: {currentPage}
+
+
+
+
+
+ {PAGES.map((label, i) => (
+
+ {label}
+
+ ))}
+
+
+ );
+}
+
+export function ModalSetPageModalScreen() {
+ const navigation =
+ useNavigation<
+ NativeStackNavigationProp
+ >();
+ const route =
+ useRoute>();
+
+ const submit = React.useCallback(() => {
+ // Intentionally synchronous: update the hidden pager, then dismiss.
+ route.params.onSubmit();
+ navigation.popTo('Issue #1083 Modal SetPage Repro');
+ }, [navigation, route]);
+
+ return (
+
+
+ Modal screen
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ flex: { flex: 1 },
+ controls: { padding: 16, gap: 8 },
+ label: { fontSize: 14, color: '#444' },
+ page: { flex: 1, alignItems: 'center', justifyContent: 'center' },
+ pageLabel: { fontSize: 32, fontWeight: '600' },
+});
diff --git a/example/src/Issue1098NestedPagerRepro.tsx b/example/src/gh-issues/Issue1098NestedPagerRepro.tsx
similarity index 100%
rename from example/src/Issue1098NestedPagerRepro.tsx
rename to example/src/gh-issues/Issue1098NestedPagerRepro.tsx
diff --git a/example/src/Issue1099SafeAreaRepro.tsx b/example/src/gh-issues/Issue1099SafeAreaRepro.tsx
similarity index 98%
rename from example/src/Issue1099SafeAreaRepro.tsx
rename to example/src/gh-issues/Issue1099SafeAreaRepro.tsx
index 7af682fa..d86d5d7e 100644
--- a/example/src/Issue1099SafeAreaRepro.tsx
+++ b/example/src/gh-issues/Issue1099SafeAreaRepro.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import PagerView from 'react-native-pager-view';
-import { IMAGE_URIS } from './utils';
+import { IMAGE_URIS } from '../utils';
const CARD_HEIGHT = 500;
diff --git a/example/visionos/.xcode.env b/example/visionos/.xcode.env
deleted file mode 100644
index 3d5782c7..00000000
--- a/example/visionos/.xcode.env
+++ /dev/null
@@ -1,11 +0,0 @@
-# This `.xcode.env` file is versioned and is used to source the environment
-# used when running script phases inside Xcode.
-# To customize your local environment, you can create an `.xcode.env.local`
-# file that is not versioned.
-
-# NODE_BINARY variable contains the PATH to the node executable.
-#
-# Customize the NODE_BINARY variable here.
-# For example, to use nvm with brew, add the following line
-# . "$(brew --prefix nvm)/nvm.sh" --no-use
-export NODE_BINARY=$(command -v node)
diff --git a/ios/PagerViewProvider.swift b/ios/PagerViewProvider.swift
index 644a0575..aa9c374f 100644
--- a/ios/PagerViewProvider.swift
+++ b/ios/PagerViewProvider.swift
@@ -118,6 +118,57 @@ import UIKit
}
@objc public func goTo(index: Int, animated: Bool) {
+ if animated && hasPresentedViewController() {
+ // A native-stack modal can begin its dismissal in the same JavaScript
+ // callback as `setPage`. Starting a SwiftUI TabView animation in that
+ // transaction makes TabView apply the selection twice, landing one page
+ // too far. Let UIKit register the dismissal first, then perform the
+ // pager animation after the transition has completed.
+ // Native-stack may need several run-loop passes before its transition
+ // coordinator becomes observable. Check briefly for that coordinator
+ // instead of starting the SwiftUI animation on the very next pass.
+ setPageWhenPresentationTransitionCompletes(
+ index: index,
+ attemptsRemaining: 10
+ )
+ } else {
+ setPage(index: index, animated: animated)
+ }
+ }
+
+ private func setPageWhenPresentationTransitionCompletes(
+ index: Int,
+ attemptsRemaining: Int
+ ) {
+ guard let transitionCoordinator = activeTransitionCoordinator() else {
+ guard attemptsRemaining > 0 && hasPresentedViewController() else {
+ setPage(index: index, animated: true)
+ return
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { [weak self] in
+ self?.setPageWhenPresentationTransitionCompletes(
+ index: index,
+ attemptsRemaining: attemptsRemaining - 1
+ )
+ }
+ return
+ }
+
+ let didSchedulePageChange = transitionCoordinator.animate(
+ alongsideTransition: nil
+ ) { [weak self] _ in
+ DispatchQueue.main.async {
+ self?.setPage(index: index, animated: true)
+ }
+ }
+
+ if !didSchedulePageChange {
+ setPage(index: index, animated: true)
+ }
+ }
+
+ private func setPage(index: Int, animated: Bool) {
if animated {
withAnimation {
props.currentPage = index
@@ -127,6 +178,40 @@ import UIKit
}
}
+ /// Finds the native-stack transition that may be dismissing a modal over
+ /// this pager. The coordinator becomes available only after the imperative
+ /// command has returned to the main run loop.
+ private func activeTransitionCoordinator() -> UIViewControllerTransitionCoordinator? {
+ var viewController = reactViewController()
+ while let current = viewController {
+ if let transitionCoordinator = current.transitionCoordinator {
+ return transitionCoordinator
+ }
+ viewController = current.parent
+ }
+
+ var rootViewController = window?.rootViewController
+ while let current = rootViewController {
+ if let transitionCoordinator = current.transitionCoordinator {
+ return transitionCoordinator
+ }
+ rootViewController = current.presentedViewController
+ }
+
+ return nil
+ }
+
+ private func hasPresentedViewController() -> Bool {
+ var viewController = reactViewController()
+ while let current = viewController {
+ if current.presentedViewController != nil {
+ return true
+ }
+ viewController = current.parent
+ }
+ return false
+ }
+
private func setupView() {
if self.hostingController != nil {
syncParentViewController()
diff --git a/scripts/run-maestro-tests.sh b/scripts/run-maestro-tests.sh
index 160c2236..6af663d7 100644
--- a/scripts/run-maestro-tests.sh
+++ b/scripts/run-maestro-tests.sh
@@ -99,6 +99,7 @@ fi
shopt -s nullglob
allTestFiles=(
.maestro/tests/*.yaml
+ .maestro/issues/*.yaml
.maestro/"$PLATFORM"-only/*.yaml
)