镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

fix(ios): show a page that SwiftUI hosts again after setPage - #1203

Open
KisaneNeko wants to merge 2 commits into
callstack:mainfrom
KisaneNeko:fix/ios-blank-page-after-setpage
Open

KisaneNeko wants to merge 2 commits into
callstack:mainfrom
KisaneNeko:fix/ios-blank-page-after-setpage

Conversation

@KisaneNeko

@KisaneNeko KisaneNeko commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #1202. It surfaced while reviewing #1193, whose repro screen triggers it, but it happens on 9.0.10 without that PR.

On iOS, a page shown by setPage can be blank. The pager reports the page as selected, but the page has no content and stays empty after the pager stops.

The recording in #1202 shows the bug.

What happened

  • Each page is one React Native view. PageChildViewController adds it to its own view once, in viewDidLoad.
  • SwiftUI can keep two controllers alive for the same page. A view has one parent, so the newer controller takes the page view from the older one.
  • When SwiftUI removes the newer controller, the page view is left with no parent. When the page shows again, SwiftUI can use the older controller, which does not add the view again. The cell is on screen but empty.

I traced this with logging: in the blank state, the visible cell's controller wraps the right page view, but that view has no superview.

Fix

The collectionView(_:willDisplay:forItemAt:) handler in PagerScrollDelegate now also finds the cell's PageChildViewController before the cell shows. If the controller's page view has no parent, the controller takes it back.

  • The "no parent" check matters. At willDisplay a reused cell can still hold an older controller while SwiftUI is about to show a newer one. Taking the view unconditionally blanked page 0 in the basic example, which the basic-pager e2e tests caught.
  • viewDidLoad is unchanged.

Test Plan

What's required for testing (prerequisites)?

iOS simulator or device. The new example screen, which re-renders the event log above the pager on every page, focus and keyboard event.

What are the steps to reproduce (after prerequisites)?

Added Issue #1202 Blank Page Repro to the example app:

  1. On page 0, tap the input, type, and tap Go 1.
  2. Do the same on page 1 (Go 2), page 2 (Go 1), page 1 (Go 0) and page 0 (Go 1).
  3. Tap Go 2.
  4. Before: sometimes the status says Selected: 2, but the pager area is empty.
  5. After: page 2 shows.

Verified

This is a timing issue. Yesterday it reproduced every time: an e2e test for it failed 3/3 without the fix and passed 3/3 with it. Today the same test passes without the fix too, so I left it out of this PR.

The fix is based on the logs from the failing runs. When a page view has no parent, the fix puts it back. In every other case it does not change the view tree, and the pager behaves as before.

  • iPhone 17 Pro Max simulator, iOS 26.5, release build.
  • Full iOS e2e suite with the fix: 21/21 tests pass.
  • typecheck (root, example and e2e), lint (no warnings in the changed files), test and prettier pass.
  • Not tested on iOS 15 or visionOS, or on a physical device.

Android is untouched.

Compatibility

OS Implemented
iOS ✅
Android n/a

Checklist

  • Tested on a device and a simulator: simulator only (iPhone 17 Pro Max, iOS 26.5), not on physical hardware
  • I added the documentation in README.md: not applicable
  • I updated the typed files (TS and Flow): not applicable

🤖 Generated with Claude Code

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 callstack#1202
@KisaneNeko
KisaneNeko force-pushed the fix/ios-blank-page-after-setpage branch from d15d62d to e5a5b91 Compare October 27, 2026 08:26
@KisaneNeko
KisaneNeko marked this pull request as ready for review October 27, 2026 09:17
@troZee

troZee commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

@KisaneNeko could you resolve the conflicts?

…ter-setpage

# Conflicts:
#	example/src/App.tsx
@troZee

troZee commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

@apex-sentinel-staging review

@apex-sentinel-staging

Copy link
Copy Markdown

Apex Sentinel

Verdict: approve

1 finding(s): 1 nit. Verdict: approve. reached max rounds (1).

🔵 Nit (1)
Where Finding
ios/PagerScrollDelegate.swift:85 willDisplay walks the cell's entire view tree recursively on every display

Reply under an inline comment with @apex-sentinel-staging note ... or @apex-sentinel-staging pivot ... to re-check that finding.
Comment @apex-sentinel-staging review on this conversation to run a full review.

@apex-sentinel-staging apex-sentinel-staging Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apex Sentinel: 1 new finding on 83a74e1. The verdict and the full list are in the summary comment.

@@ -84,6 +84,19 @@ class PagerScrollDelegate: NSObject, UIScrollViewDelegate, UICollectionViewDeleg

func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nit · willDisplay walks the cell's entire view tree recursively on every display

pageController(in:) is a full pre-order DFS over cell.contentView's subview tree, and collectionView(_:willDisplay:forItemAt:) fires for every cell that becomes visible — during every swipe, every reuse, and every time SwiftUI re-displays a cell after a re-render (exactly the re-render-on-every-event pattern the new repro screen exercises). The wrapped page view is an arbitrary React Native subtree: for a page containing a long ScrollView/list, any sibling branch that precedes the PageChildViewController.view branch is traversed in full before it is found, and each node runs an Objective-C next message plus a type check. Nothing memoizes the result, so the cost is O(subviews of the page) per display on the main thread during scroll animation, where a stutter is visible.

Suggested change

Avoid an unbounded recursive subview walk on the collection view's hot display path: bound the search depth (SwiftUI's hosting wrapper around the Representable's controller view is shallow) or cache the resolved PageChildViewController per cell so repeated willDisplay calls do not re-walk an arbitrary React Native subtree on the main thread.

Details

A cheaper version stops as soon as it reaches a UIHostingView boundary / the Representable's container instead of descending into arbitrary RN content, or only descends while no PageChildViewController has been found and bails once a subtree is known not to contain one (e.g. cache the resolved controller per cell).

Also raised here

Also raised at this location by maintainability: willDisplay reclaims the first PageChildViewController found in the whole cell subtree, not the cell's own — PagerScrollDelegate.swift:87-99 — pageController(in:) is a recursive depth-first walk that returns the first PageChildViewController it finds, so for a page that itself contains a nested PagerView (a supported pattern, and exercised by the existing Issue1156NestedHorizontalScrollRepro screen) the first hit can be an inner page's controller rather than the outer cell's. The reclaimWrappedViewIfOrphaned() call then acts on the wrong page — taking back an inner orphaned view — while the actually-orphaned outer page view stays parentless and blank, which is the bug this PR is fixing. The guard clause in reclaimWrappedViewIfOrphaned protects against stealing a live view, but it does not constrain which controller is picked. Searching the cell's own controller first (e.g. cell.contentView.next, then a depth-limited/subviews traversal that stops at any nested PageChildViewController it finds) would target the right page.

Also raised at this location by javascript: Reclaim lookup scans the page's whole view tree and can resolve to a nested pager's controller — The new pageController(in:) is an unbounded recursive DFS over cell.contentView and every descendant, returning the first PageChildViewController found in shallowest-first order, and it runs on every willDisplay (i.e. again on every cell reuse during a swipe). Two costs: (1) the traversal scales with the page's React Native view tree — hundreds of nodes for a real page, on the main thread, once per page appearance — while only one responder-chain answer is wanted; (2) view.next as? PageChildViewController is tested at every descendant, so a page that itself hosts a PagerView (a supported composition, e.g. example/src/gh-issues/Issue1156NestedHorizontalScrollRepro.tsx) can resolve to whichever controller's view sits shallower in the tree. When that is the inner controller, reclaimWrappedViewIfOrphaned() re-parents the inner pager's page view into the wrong controller as soon as its superview is momentarily nil, mutating a view tree the outer fix has no claim on. …

persona: swift-ios, javascript, maintainability, senior-developer

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[iOS] Page is blank after setPage when the screen re-renders on pager events

3 participants