From 386415a64027707ee79a764c60d870e923216c40 Mon Sep 17 00:00:00 2001 From: Sadjad Asadi Date: Sun, 13 Sep 2026 12:32:01 +0200 Subject: [PATCH] fix(ios): install the scroll delegate once per collection view `PagerScrollDelegate` keeps the delegate it displaced in `originalDelegate` and forwards unhandled selectors to it. The install block was gated on `originalDelegate == nil`, but that reference is weak, so it cannot tell "not installed yet" from "installed, but the weak reference died". The `.introspect` closure re-runs on every layout pass, so once the displaced delegate deallocated the block ran again and re-adopted `collectionView.delegate` - by then the `PagerScrollDelegate` itself. `responds(to:)` then called itself until the stack overflowed (EXC_BAD_ACCESS, code=2). This is reachable from ordinary use: SwiftUI rebuilds the TabView when `.id(props.children.count)` changes, i.e. whenever the page count changes. - Install once per collection view, keyed on the collection view's identity. A new collection view is still installed into, so a page-count change keeps working; the same one is never re-adopted, so the cycle cannot form. - Guard `responds(to:)` against re-entry, so a cycle formed another way terminates instead of overflowing. An analytics SDK that swizzles the delegate setter can insert a proxy that forwards back here, which makes the chain cyclic even when the delegate is not literally `self`. UIKit skips optional delegate methods that report `false`. - Reject a self-assignment to `originalDelegate` in `didSet`. Fixes #1146 Co-Authored-By: Claude Opus 5 --- ios/PagerScrollDelegate.swift | 32 +++++++++++++++++++++++++++++--- ios/PagerView.swift | 9 ++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/ios/PagerScrollDelegate.swift b/ios/PagerScrollDelegate.swift index 5b09e032..9d8ca373 100644 --- a/ios/PagerScrollDelegate.swift +++ b/ios/PagerScrollDelegate.swift @@ -4,10 +4,22 @@ import UIKit Scroll delegate used to control underlying TabView's collection view. */ class PagerScrollDelegate: NSObject, UIScrollViewDelegate, UICollectionViewDelegate { - weak var originalDelegate: UICollectionViewDelegate? + weak var originalDelegate: UICollectionViewDelegate? { + didSet { + // Never proxy to ourselves: that makes `responds(to:)` recurse forever. + if originalDelegate === self { originalDelegate = nil } + } + } weak var delegate: PagerViewProviderDelegate? var orientation: UICollectionView.ScrollDirection = .horizontal + /// The collection view we already installed ourselves into. Identity, not + /// `originalDelegate == nil`, is what tells us the install already ran: + /// `originalDelegate` is weak and can go nil on its own. + weak var installedCollectionView: UICollectionView? + + private var isQueryingOriginalDelegate = false + private let handledSelectors: Set = [ #selector(scrollViewDidScroll(_:)), #selector(scrollViewWillBeginDragging(_:)), @@ -86,11 +98,25 @@ class PagerScrollDelegate: NSObject, UIScrollViewDelegate, UICollectionViewDeleg } override func responds(to aSelector: Selector!) -> Bool { - handledSelectors.contains(aSelector) || (originalDelegate?.responds(to: aSelector) ?? false) + if handledSelectors.contains(aSelector) { return true } + + // An analytics SDK may swizzle the collection view's delegate setter and + // insert a proxy that forwards `responds(to:)` back to whatever it + // replaced - this object. Querying `originalDelegate` then re-enters this + // method through that proxy and overflows the stack. Report `false` for a + // re-entrant query so the loop terminates; UIKit skips optional delegate + // methods that answer `false`. Delegate callbacks are main-thread only. + guard !isQueryingOriginalDelegate else { return false } + isQueryingOriginalDelegate = true + defer { isQueryingOriginalDelegate = false } + + return originalDelegate?.responds(to: aSelector) ?? false } override func forwardingTarget(for aSelector: Selector!) -> Any? { - handledSelectors.contains(aSelector) ? nil : originalDelegate + guard !handledSelectors.contains(aSelector) else { return nil } + let target = originalDelegate + return target === self ? nil : target } } diff --git a/ios/PagerView.swift b/ios/PagerView.swift index 5d90f7f6..51462b7d 100644 --- a/ios/PagerView.swift +++ b/ios/PagerView.swift @@ -45,11 +45,18 @@ struct PagerView: View { collectionView.showsVerticalScrollIndicator = false collectionView.showsHorizontalScrollIndicator = false - if scrollDelegate.originalDelegate == nil { + // Install once per collection view. This closure re-runs on every + // layout pass, and `originalDelegate == nil` cannot tell "not installed + // yet" from "installed, but the weak reference died" - so once it died + // we re-adopted `collectionView.delegate`, which by then is this object + // (or a proxy forwarding back to it). Either way the delegate chain + // becomes a cycle and recurses until the stack overflows. + if scrollDelegate.installedCollectionView !== collectionView { scrollDelegate.originalDelegate = collectionView.delegate scrollDelegate.delegate = delegate // VTabView-style rotation preserves TabView's horizontal collection view. scrollDelegate.orientation = .horizontal + scrollDelegate.installedCollectionView = collectionView collectionView.delegate = scrollDelegate } }