fix(ios): install the scroll delegate once per collection view - #1147
Open
lvlrSajjad wants to merge 1 commit into
Open
lvlrSajjad wants to merge 1 commit into
lvlrSajjad wants to merge 1 commit into
Conversation
`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 callstack#1146 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1146.
The bug
PagerScrollDelegateproxies the TabView collection view's delegate: it keeps the delegate it displaced inoriginalDelegateand forwards unhandled selectors to it. The install block was gated onoriginalDelegate == nil— but that reference isweak, so it cannot distinguish "not installed yet" from "installed, but the weak reference died"..introspectre-runs on every layout pass, so once the displaced delegate deallocated, the block ran again and re-adoptedcollectionView.delegate— which by then is thePagerScrollDelegateitself.responds(to:)then calls itself until the stack overflows (EXC_BAD_ACCESS, code=2, guard page).originalDelegatedying is reachable from ordinary use: SwiftUI rebuilds theTabViewwhen.id(props.children.count)changes, i.e. whenever the page count changes. We hit it via@react-navigation/material-top-tabson screens that add or drop a tab once data loads.The fix
installedCollectionView) rather than the weak reference. A new collection view is still installed into — so a page-count change keeps working — but the same one is never re-adopted, so the cycle cannot form. This is the actual fix.responds(to:), as defense-in-depth. An analytics SDK that swizzles the delegate setter (Pendo, in our case) inserts a proxy that forwards back here, so the chain can be cyclic even when the delegate is not literallyself. Handled selectors returntruebefore the guard, so the eight selectors driving the pager's events are untouched.originalDelegateindidSet.Verification
I compiled
PagerScrollDelegate.swiftfrom this branch against the iOS SDK and ran it against a proxy reproducing an SDK's swizzling —responds(to:)forwarding plus a realforwardingTarget— through the full sequence: install → proxy wraps us → displaced delegate deallocates → closure re-runs → scroll arrives → collection view replaced.responds(to:)on a cyclic chainEXC_BAD_ACCESS(code=2)EXC_BAD_ACCESS(code=2)onPageScrollfires once per scrollEXC_BAD_ACCESS(code=2)The third row is worth calling out: the crash reproduces with no analytics SDK involved at all. A swizzling SDK only widens the window and adds the event-forwarding path.
Notes
🤖 Generated with Claude Code