From d15d62d76c2b69d0ffc7fc691462166c064f5e2a Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Mon, 26 Oct 2026 17:09:04 +0100 Subject: [PATCH] fix(ios): show a page that SwiftUI hosts again after setPage Each page is one React Native view, which PageChildViewController adds to its own view once, in viewDidLoad. SwiftUI can keep two controllers alive for the same page. The newer one takes the page view, and when SwiftUI removes it the view is left with no parent. If SwiftUI then shows the older controller again, nothing adds the view back, and the page is blank. When the collection view is about to display a cell, its controller now takes the page view back if the view has no parent. A view that a newer controller holds is left alone. Fixes #1202 --- e2e/tests/issues/issue-1202-blank-page.e2e.ts | 40 +++++ example/src/App.tsx | 5 + .../src/gh-issues/Issue1202BlankPageRepro.tsx | 142 ++++++++++++++++++ ios/Extensions.swift | 12 ++ ios/PagerScrollDelegate.swift | 13 ++ 5 files changed, 212 insertions(+) create mode 100644 e2e/tests/issues/issue-1202-blank-page.e2e.ts create mode 100644 example/src/gh-issues/Issue1202BlankPageRepro.tsx diff --git a/e2e/tests/issues/issue-1202-blank-page.e2e.ts b/e2e/tests/issues/issue-1202-blank-page.e2e.ts new file mode 100644 index 00000000..ca92c78e --- /dev/null +++ b/e2e/tests/issues/issue-1202-blank-page.e2e.ts @@ -0,0 +1,40 @@ +import { freshAppBeforeEach, expect, test } from '../support/test.ts'; +import type { Screen } from '../support/test.ts'; +import { openExample } from '../support/app.ts'; + +freshAppBeforeEach(); + +/** Focuses the input on `page`, types into it, then pages with Go `next`. */ +async function typeThenGo( + screen: Screen, + page: number, + next: number +): Promise { + const input = screen.getByTestId(`issue-1202-input-${page}`); + await input.tap(); + await input.pressSequentially(`${page}${page}${page}${page}`); + await screen.getByTestId(`issue-1202-go-${next}`).tap(); + await expect(screen.getByTestId(`issue-1202-label-${next}`)).toBeVisible(); +} + +test( + 'a page shown by setPage is not blank', + // iOS-only: the blank page comes from SwiftUI hosting a page twice. + { tags: ['regression', 'issue-1202'], platforms: ['ios'] }, + async ({ app, screen }) => { + await openExample(app, screen, 'Issue #1202 Blank Page Repro'); + + await typeThenGo(screen, 0, 1); + await typeThenGo(screen, 1, 2); + await typeThenGo(screen, 2, 1); + await typeThenGo(screen, 1, 0); + await typeThenGo(screen, 0, 1); + + // Before the fix page 2 was selected here but showed nothing. + await screen.getByTestId('issue-1202-go-2').tap(); + await expect(screen.getByTestId('issue-1202-status')).toHaveText( + /^Selected: 2 \|/ + ); + await expect(screen.getByTestId('issue-1202-label-2')).toBeVisible(); + } +); diff --git a/example/src/App.tsx b/example/src/App.tsx index 92f5d899..ccdebfd7 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -60,6 +60,7 @@ import { } from './gh-issues/Issue1142SearchBarInsetRepro'; import { Issue1154PageChangeFocusRepro } from './gh-issues/Issue1154PageChangeFocusRepro'; import { Issue1156NestedHorizontalScrollRepro } from './gh-issues/Issue1156NestedHorizontalScrollRepro'; +import { Issue1202BlankPageRepro } from './gh-issues/Issue1202BlankPageRepro'; import { Issue424SwipeCancelsPressRepro } from './gh-issues/Issue424SwipeCancelsPressRepro'; import { Issue965PagesBeforeCurrentRepro } from './gh-issues/Issue965PagesBeforeCurrentRepro'; import { colors, radius } from './theme'; @@ -188,6 +189,10 @@ const sections: { title: string; data: Example[] }[] = [ component: Issue1156NestedHorizontalScrollRepro, name: 'Issue #1156 Nested Horizontal Scroll Repro', }, + { + component: Issue1202BlankPageRepro, + name: 'Issue #1202 Blank Page Repro', + }, { component: Issue424SwipeCancelsPressRepro, name: 'Issue #424 Swipe Cancels Press Repro', diff --git a/example/src/gh-issues/Issue1202BlankPageRepro.tsx b/example/src/gh-issues/Issue1202BlankPageRepro.tsx new file mode 100644 index 00000000..12d9945a --- /dev/null +++ b/example/src/gh-issues/Issue1202BlankPageRepro.tsx @@ -0,0 +1,142 @@ +/** + * Repro for #1202: on iOS, a page shown by setPage must not be blank. The + * event log above the pager is cleared by each Go button and grows on every + * pager, focus and keyboard event, so the pager is laid out again around each + * page change. Focusing and typing on several pages and then paging with the + * Go buttons used to leave the selected page empty. + */ +import React, { useEffect, useRef, useState } from 'react'; +import { + Button, + Keyboard, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import PagerView from 'react-native-pager-view'; + +const PAGES = ['Page 0', 'Page 1', 'Page 2']; +const PAGE_COLORS = ['#fde2e2', '#e2fde6', '#e2e8fd']; + +export function Issue1202BlankPageRepro() { + const pagerRef = useRef(null); + const [selectedPage, setSelectedPage] = useState(0); + const [focusedInput, setFocusedInput] = useState(null); + const [events, setEvents] = useState([]); + const log = (event: string) => + setEvents((current) => [...current.slice(-11), event]); + + useEffect(() => { + const show = Keyboard.addListener('keyboardDidShow', () => { + log('keyboardShow'); + }); + const hide = Keyboard.addListener('keyboardDidHide', () => { + log('keyboardHide'); + }); + return () => { + show.remove(); + hide.remove(); + }; + }, []); + + return ( + + + Selected: {selectedPage} | Focused:{' '} + {focusedInput === null ? 'none' : `input ${focusedInput}`} + + + Events: {events.join(', ')} + + + {PAGES.map((label, i) => ( +