[iOS] Retained page can become detached when temporary SwiftUI page hosts are loaded/discarded (9.0.7)

Đang mở
#1,222 2 bình luận 1 reaction 0 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

Chưa có ai nhận issue này.

Đánh giá

Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức phù hợp với người mới
32/100
Loại issue
Lỗi
Độ rõ ràng
Khá rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
ios, react-native, swift
Lĩnh vực
mobile

Hướng nghiên cứu

The bug is in ios/Extensions.swift, where PageChildViewController moves the retained wrappedView from viewDidLoad. Start by reading that file and the viewDidAppear gesture-container logic, which must be kept, then run the attached PagerHostRegression.swift harness with run-pager-regression.sh on a booted simulator. Done means all 10 checks pass, including the one where a temporary off-window host cannot steal the visible page.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

Resolution: Needs Repro

Update: November 5, 2026 — chaos-test follow-up: repeatable seeded harness, exact failing negative control, and integration testing results. The revised local patch passes 1,500 cycles / 43,260 assertions; the preceding local workaround fails at seed 151, cycle 5. Current-upstream/#1203 validation remains outstanding.

Update: November 5, 2026 — The original workaround below is incomplete. See the follow-up with revised reproduction steps, 17 native checks, and an updated patch. Related upstream #1203 has now merged; testing our integration against it remains outstanding.

Summary

An iOS pager page can remain blank after switching pages even though React still has the selected page and its content mounted. In a captured failure, the pager was at the correct offset, but the current PageChildViewController retained a wrappedView whose superview and window were both nil. Reattaching that same native view restored the content immediately.

We traced a page transfer from PageChildViewController.viewDidLoad(), before the replacement host entered a window. A temporary SwiftUI host can take the retained page and later be discarded, leaving the surviving host empty. Below are sanitized evidence, the current local patch, and a generic native regression harness for discussion.
Update: The evidence and original workaround below are preserved for context. The workaround is incomplete; see the November 5 follow-up for two additional cleanup cases, the revised patch, and a 17-check harness.

Possibly related: #1202 reports a selected-but-blank page after pager-event re-renders. We have not established that it has the same cause. #1098 documents related host-replacement/controller-containment behavior, but its provider-parenting failure is a different location.

Environment

Component Observed version
react-native-pager-view 9.0.7
React Native 0.88.0, Fabric
React 19.3.0
Expo 58.0.16
Expo Router 58.0.24
react-native-screens 4.30.0
react-native-tab-view 4.3.4
Platform iOS 27.0 Simulator, iPhone 18 Pro

The integration has a horizontal tab pager with retained pages containing native stacks. First visits can replace placeholder routes and change native children while SwiftUI page hosts are recreated.

Baseline qualification: the integration already carried a local patch to move native-stack controller containment safely between page hosts. The captured trace and pre-fix test output below are from locally patched 9.0.7, not a claim of a pristine-package end-to-end reproduction. The proposed diff is against the published 9.0.7 source and includes that containment handling.

Code inspection of current main/ios/Extensions.swift still shows attachment from viewDidLoad, but we have not run this reproduction on current main. Its existing viewDidAppear gesture-container resolution must be preserved if porting the patch; do not replace that callback wholesale with the 9.0.7 version below.
Update: #1203 merged on November 4 and adds orphaned-page recovery from PagerScrollDelegate.collectionView(_:willDisplay:forItemAt:). We have not tested that upstream version, so this report does not establish a remaining failure after #1203. Any port must preserve both the upstream recovery and gesture-container behavior.

Observed behavior and evidence

In the integration, repeatedly visiting pages through tab buttons and swiping between them could produce a persistent empty page. It was intermittent, rather than a deterministic sequence of specific gestures.

During a captured failure:

  • React navigation selected the intended page; content was loaded and still mounted.
  • The content's animation/visibility state was visible.
  • The native collection view's horizontal offset matched the selected index.
  • The visible native page host was empty, while its retained React view still had a populated subtree and a valid frame.
  • That retained view had no superview and no window.
  • Manually reattaching the same retained view restored the page without fetching data or changing React navigation.

Expected: creating/discarding temporary hosts must leave the visible page attached, and a real replacement must transfer the page and any relevant controller containment safely.

Actual: the retained page can be taken during initial loading of a replacement host and left detached; repeated initial-only attachment logic does not restore it.

Sanitized native diagnostic excerpts

Addresses are replaced with placeholders. Application module names are replaced with ExampleApp; process IDs, device identifiers, local paths and application content are omitted. These are selected excerpts, not a complete raw log.

(lldb) expr -l objc++ -O -- [(id)<page-view-address> superview]
nil
(lldb) expr -l objc++ -O -- [(id)<page-view-address> window]
nil

Call stack at removal from the previous host. attachWrappedViewIfNeeded is the local helper present in the instrumented revision, not an upstream symbol; the relevant point is the transfer being initiated from viewDidLoad:

   * frame #0: <address> UIKitCore`-[UIView(Hierarchy) removeFromSuperview]
     frame #1: <address> ExampleApp.debug.dylib`PageChildViewController.attachWrappedViewIfNeeded() at Extensions.swift:65:17
  ﹍ frame #2: <address> ExampleApp.debug.dylib`PageChildViewController.viewDidLoad() at Extensions.swift:40:5
  ﹉ frame #4: <address> UIKitCore`-[UIViewController _sendViewDidLoadWithAppearanceProxyObjectTaggingEnabled] + 88
     frame #5: <address> UIKitCore`-[UIViewController loadViewIfRequired] + 916
     frame #6: <address> UIKitCore`-[UIViewController view] + 20
     frame #7: <address> SwiftUI`static PlatformViewRepresentable<>.platformView(for:) + 12
     frame #8: <address> SwiftUI`protocol witness for static CoreViewRepresentable.platformView(for:) in conformance PlatformViewControllerRepresentableAdaptor<τ_0_0> + 36

Native lifecycle reproduction

The focused test isolates the host behavior without any app-specific screens, network calls, navigation configuration or animations:

  1. Mount a PageChildViewController with a retained page inside a real UIWindow.
  2. Create another PageChildViewController referencing the same page.
  3. Call loadViewIfNeeded() on the second host, without inserting that host into a window.
  4. Assert that the page still belongs to the first, visible host.
  5. Discard the temporary host and verify the visible page remains attached.
  6. Mount a real replacement, then exercise former-host callbacks and explicit page detachment/recovery.

The test also checks containment using a generic child UIViewController. In the full integration that child is a native-stack controller. An implementation without safe containment transfer may raise a UIKit hierarchy exception when moving such a page; that is why the proposed patch includes both containment and attachment handling.

This is a UIKit lifecycle regression harness, not a complete minimal React Native/SwiftUI gesture reproduction. It compiles the actual Extensions.swift implementation with minimal surrounding pager types.
Update: This original harness manually calls the surviving host's layout method after removing a replacement, which masks a later cleanup failure. The updated harness removes that assumption and also covers controller release before container removal. A minimal integration reproduction against current upstream is still outstanding.

Complete generic harness: PagerHostRegression.swift

Save this next to a copy of ios/Extensions.swift from the version under test. The two pager type stubs let the unmodified remainder of that file compile; the attachment code itself is not mocked.

import UIKit
import SwiftUI

// Only the surrounding pager types are stubbed. The test compiles the actual
// patched Extensions.swift and runs its controllers inside an iOS window.
class PagerViewProvider: UIView {}
struct PagerView: View { var body: some View { EmptyView() } }

@main
class PagerRegressionApp: UIResponder, UIApplicationDelegate {
  var window: UIWindow?

  func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    return true
  }

  func application(_ application: UIApplication, configurationForConnecting session: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
    let config = UISceneConfiguration(name: "Regression", sessionRole: session.role)
    config.delegateClass = PagerRegressionScene.self
    return config
  }

  func check(_ condition: Bool, _ name: String) {
    print("\(condition ? "PASS" : "FAIL"): \(name)")
    fflush(stdout)
    if !condition { exit(1) }
  }

  func mount(_ host: PageChildViewController, in container: UIViewController) {
    container.addChild(host)
    host.view.frame = container.view.bounds
    container.view.addSubview(host.view)
    host.didMove(toParent: container)
    host.viewDidLayoutSubviews()
  }

  func runChecks(in container: UIViewController) {
    let page = UIView(frame: container.view.bounds)
    let first = PageChildViewController()
    first.wrappedView = page
    mount(first, in: container)
    check(page.superview === first.view && page.window != nil, "initial mounted page is attached")

    let navigation = UIViewController()
    first.addChild(navigation)
    page.addSubview(navigation.view)
    navigation.didMove(toParent: first)

    autoreleasepool {
      let temporary = PageChildViewController()
      temporary.wrappedView = page
      temporary.loadViewIfNeeded()
      temporary.viewDidLayoutSubviews()
      check(page.superview === first.view, "temporary off-window host cannot steal visible page")
      check(navigation.parent === first, "temporary host preserves native controller containment")
    }
    first.viewDidLayoutSubviews()
    check(page.superview === first.view, "discarding temporary host leaves visible page intact")

    let replacement = PageChildViewController()
    replacement.wrappedView = page
    mount(replacement, in: container)
    check(page.superview === replacement.view && navigation.parent === replacement, "mounted replacement transfers page and native controller")
    first.viewDidLayoutSubviews()
    first.viewWillAppear(false)
    check(page.superview === replacement.view, "old host callbacks cannot reclaim displayed page")

    page.removeFromSuperview()
    replacement.viewDidLayoutSubviews()
    check(page.superview === replacement.view && page.window != nil, "layout repairs detached page")
    page.removeFromSuperview()
    replacement.viewDidAppear(false)
    check(page.superview === replacement.view && navigation.parent === replacement, "appearance repairs detached page")

    replacement.view.removeFromSuperview()
    first.viewDidLayoutSubviews()
    check(page.superview === first.view && navigation.parent === first, "surviving mounted host recovers page from removed replacement")
    replacement.viewDidLayoutSubviews()
    check(page.superview === first.view, "off-window former host cannot steal recovered page")
    print("Pager host regression: 10 checks passed")
  }
}

class PagerRegressionScene: UIResponder, UIWindowSceneDelegate {
  var window: UIWindow?

  func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
    guard let scene = scene as? UIWindowScene else { return }
    let window = UIWindow(windowScene: scene)
    let container = UIViewController()
    window.rootViewController = container
    self.window = window
    window.makeKeyAndVisible()
    DispatchQueue.main.async {
      (UIApplication.shared.delegate as! PagerRegressionApp).runChecks(in: container)
      exit(0)
    }
  }
}
Standalone macOS/Simulator runner: run-pager-regression.sh

Place Extensions.swift, PagerHostRegression.swift, and this script in one directory, then run:

bash run-pager-regression.sh <booted-simulator-udid>

Requires Xcode command-line tools, Python 3, and ripgrep. It briefly opens a disposable helper app with the generic bundle identifier org.example.pagerregression, then removes the helper app.

#!/usr/bin/env bash
set -euo pipefail

repro_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
device="${1:?Usage: bash run-pager-regression.sh <booted-simulator-udid>}"
test_directory="$(mktemp -d "${TMPDIR:-/tmp}/pager-regression.XXXXXX")"
app_directory="$test_directory/PagerHostRegression.app"
app_identifier="org.example.pagerregression"
cleanup() {
  xcrun simctl uninstall "$device" "$app_identifier" >/dev/null 2>&1 || true
  rm -rf -- "$test_directory"
}
trap cleanup EXIT
mkdir -p "$app_directory"

python3 - "$app_directory/Info.plist" <<'PY'
import plistlib
import sys
from pathlib import Path

Path(sys.argv[1]).write_bytes(plistlib.dumps({
    'CFBundleIdentifier': 'org.example.pagerregression',
    'CFBundleExecutable': 'PagerHostRegression',
    'CFBundleName': 'PagerHostRegression',
    'CFBundlePackageType': 'APPL',
    'MinimumOSVersion': '17.0',
    'UIDeviceFamily': [1, 2],
    'UILaunchScreen': {},
    'UIApplicationSceneManifest': {
        'UIApplicationSupportsMultipleScenes': False,
        'UISceneConfigurations': {},
    },
}))
PY

xcrun --sdk iphonesimulator swiftc \
  -sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \
  -target "$(uname -m)-apple-ios17.0-simulator" \
  "$repro_root/Extensions.swift" \
  "$repro_root/PagerHostRegression.swift" \
  -o "$app_directory/PagerHostRegression"
codesign --force --sign - "$app_directory"
xcrun simctl install "$device" "$app_directory"
xcrun simctl launch --console "$device" "$app_identifier" > "$test_directory/results.log" 2>&1
cat "$test_directory/results.log"
rg -q '^Pager host regression: 10 checks passed$' "$test_directory/results.log"

Proposed patch

The attached-in-text diff is the current local patch for 9.0.7:
Update: This is the original, incomplete 9.0.7 workaround. The follow-up contains the revised 9.0.7 patch and before/after reproduction steps.

  • Do not attach until the host view has entered a window.
  • Reuse one idempotent attachment method from representable updates, appearance, and layout.
  • Allow a newly mounted replacement to adopt the retained page.
  • Prevent a former host's later callbacks from taking back a page already displayed in another host.
  • Allow a mounted surviving host to recover a page with no window.
  • Transfer only the previous page host's child controllers whose views belong to the retained page, preserving UIKit containment.
  • Leave safe-area propagation in place.

hasAttachedWrappedView records a host's attachment history. It does not replace navigation focus or animation eligibility. The actual UIKit hierarchy determines whether the page is attached elsewhere; there is no separate weak owner registry whose expiry can block recovery.

Full current patch against react-native-pager-view 9.0.7

Update: Historical patch retained for comparison. Use the revised patch in the November 5 follow-up.

diff --git a/ios/Extensions.swift b/ios/Extensions.swift
--- a/ios/Extensions.swift
+++ b/ios/Extensions.swift
@@ -16,22 +16,81 @@
     return viewController
   }
 
-  func updateUIViewController(_ uiViewController: PageChildViewController, context: Context) {}
+  func updateUIViewController(_ uiViewController: PageChildViewController, context: Context) {
+    uiViewController.attachWrappedViewIfNeeded()
+  }
 }
 
 class PageChildViewController: UIViewController {
   var wrappedView: UIView?
+  private var hasAttachedWrappedView = false
 
   override func viewDidLoad() {
     super.viewDidLoad()
     view.backgroundColor = .clear
-    if let wrappedView {
-      view.addSubview(wrappedView)
-    }
+    attachWrappedViewIfNeeded()
+  }
+
+  override func viewWillAppear(_ animated: Bool) {
+    super.viewWillAppear(animated)
+    attachWrappedViewIfNeeded()
+  }
+
+  override func viewDidAppear(_ animated: Bool) {
+    super.viewDidAppear(animated)
+    attachWrappedViewIfNeeded()
+  }
+
+  func attachWrappedViewIfNeeded() {
+    // SwiftUI also loads temporary hosts that never enter the window. Taking
+    // a retained page in viewDidLoad strands it when such a host is discarded.
+    guard isViewLoaded,
+          view.window != nil,
+          let wrappedView,
+          wrappedView.superview !== view else { return }
+
+    // A replacement host can adopt the retained page once it is mounted.
+    // A host that already handed it over must not take it back while it is
+    // displayed elsewhere. A detached page can always recover on entry.
+    guard !hasAttachedWrappedView || wrappedView.window == nil else { return }
+
+    // SwiftUI can recreate a page host when the pager's child count changes.
+    // The retained RN page may contain a native stack whose controller still
+    // belongs to the previous host. Move that containment before moving its
+    // view, otherwise UIKit raises UIViewControllerHierarchyInconsistency.
+    let retainedControllers = controllersToReparent(in: wrappedView)
+    for controller in retainedControllers {
+      controller.willMove(toParent: nil)
+      controller.removeFromParent()
+    }
+    wrappedView.removeFromSuperview()
+    for controller in retainedControllers {
+      addChild(controller)
+    }
+    view.addSubview(wrappedView)
+    hasAttachedWrappedView = true
+    for controller in retainedControllers {
+      controller.didMove(toParent: self)
+    }
+  }
+
+  private func controllersToReparent(in wrappedView: UIView) -> [UIViewController] {
+    var responder: UIResponder? = wrappedView
+    while let current = responder {
+      if let previousHost = current as? PageChildViewController, previousHost !== self {
+        return previousHost.children.filter { controller in
+          guard let childView = controller.viewIfLoaded else { return false }
+          return childView === wrappedView || childView.isDescendant(of: wrappedView)
+        }
+      }
+      responder = current.next
+    }
+    return []
   }
 
   override func viewDidLayoutSubviews() {
     super.viewDidLayoutSubviews()
+    attachWrappedViewIfNeeded()
     propagateSafeArea()
   }

Validation and limits

The native harness was run on the Simulator. Its temporary-host check failed with the earlier local containment/recovery implementation:

PASS: initial mounted page is attached
FAIL: temporary off-window host cannot steal visible page

With the patch above:

PASS: initial mounted page is attached
PASS: temporary off-window host cannot steal visible page
PASS: temporary host preserves native controller containment
PASS: discarding temporary host leaves visible page intact
PASS: mounted replacement transfers page and native controller
PASS: old host callbacks cannot reclaim displayed page
PASS: layout repairs detached page
PASS: appearance repairs detached page
PASS: surviving mounted host recovers page from removed replacement
PASS: off-window former host cannot steal recovered page
Pager host regression: 10 checks passed

The actual integration was rebuilt with the native change. Fresh entry and repeated menu transitions retained content, and subsequent manual testing of rapid taps and horizontal swipes no longer reproduced the original blank page.
Update: Those initial checks passed, but they were not sufficient to establish that the intermittent bug was resolved. We subsequently reproduced late replacement removal and controller-before-container disposal failures. The revised local workaround passes all 17 native checks, and the rebuilt integration rendered the tested tab/reset-history/Back flows correctly. This is still limited evidence on locally patched 9.0.7, not verification of current upstream or every gesture sequence.

This is encouraging local evidence, not a claim of exhaustive platform coverage. Current upstream main, physical devices, older iOS versions, cancelled gestures and every nested-navigation/child-change combination have not all been validated. The isolated test does not establish the exact SwiftUI scheduling of every intermittent full-app failure.

Could this attachment lifecycle be addressed upstream, or is there a stronger lifecycle/ownership invariant we should use instead? We would appreciate review of the mounted-host guard and transfer behavior in particular.

Ngôn ngữ chính
TypeScript
Star
3.4k
Fork
491
Merge trung bình
2 ngày 4 giờ
Pull request đã merge (30 ngày)
51

Chuẩn bị môi trường

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của callstack/react-native-pager-view

Tất cả issue của callstack/react-native-pager-view

Issue tương tự

Thêm issue về TypeScript

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.