Skip to content

fix(ios): install the scroll delegate once per collection view - #1147

Open
lvlrSajjad wants to merge 1 commit into
callstack:masterfrom
lvlrSajjad:fix/delegate-cycle-stack-overflow
Open

lvlrSajjad wants to merge 1 commit into
callstack:masterfrom
lvlrSajjad:fix/delegate-cycle-stack-overflow

Conversation

@lvlrSajjad

Copy link
Copy Markdown

Fixes #1146.

The bug

PagerScrollDelegate proxies the TabView collection view's delegate: it 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 distinguish "not installed yet" from "installed, but the weak reference died".

.introspect re-runs on every layout pass, so once the displaced delegate deallocated, the block ran again and re-adopted collectionView.delegate — which by then is the PagerScrollDelegate itself. responds(to:) then calls itself until the stack overflows (EXC_BAD_ACCESS, code=2, guard page).

originalDelegate dying is reachable from ordinary use: SwiftUI rebuilds the TabView when .id(props.children.count) changes, i.e. whenever the page count changes. We hit it via @react-navigation/material-top-tabs on screens that add or drop a tab once data loads.

The fix

  • Install once per collection view, keyed on the collection view's identity (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.
  • Re-entrancy guard in 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 literally self. Handled selectors return true before the guard, so the eight selectors driving the pager's events are untouched.
  • Reject self-assignment to originalDelegate in didSet.

Verification

I compiled PagerScrollDelegate.swift from this branch against the iOS SDK and ran it against a proxy reproducing an SDK's swizzling — responds(to:) forwarding plus a real forwardingTarget — through the full sequence: install → proxy wraps us → displaced delegate deallocates → closure re-runs → scroll arrives → collection view replaced.

before after
responds(to:) on a cyclic chain EXC_BAD_ACCESS (code=2) returns, no crash
event forwarding on a cyclic chain EXC_BAD_ACCESS (code=2) onPageScroll fires once per scroll
self-cycle, no SDK present EXC_BAD_ACCESS (code=2) cycle never forms
all 8 handled selectors
replacement collection view installed into still installed into

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

  • iOS only; no JS or Android changes.
  • No public API change.
  • Behaviour is unchanged on the non-cyclic path: the same delegate is adopted and forwarded to as before.

🤖 Generated with Claude Code

`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>
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: PagerScrollDelegate proxies to itself and overflows the stack when the weak originalDelegate dies

1 participant