Skip to content

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

Open
lvlrSajjad wants to merge 2 commits into
callstack:mainfrom
lvlrSajjad:fix/delegate-cycle-stack-overflow
Open

lvlrSajjad wants to merge 2 commits into
callstack:mainfrom
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

lvlrSajjad and others added 2 commits September 13, 2026 12:32
`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>
Resolve the conflict in ios/PagerView.swift by keeping the iOS 26 edge
effect hiding for the vertical pager (callstack#1149) ahead of the once-per-collection-view
delegate install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@troZee

troZee commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

Hello @lvlrSajjad
Thank you for your contribution. How can this issue be reproduced? Could you also provide bfore/after video?

@troZee troZee added the ios label Oct 8, 2026
@troZee

troZee commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

@apex-sentinel-staging review

@apex-sentinel-staging

Copy link
Copy Markdown

Apex Sentinel

Verdict: request_changes

7 finding(s): 1 major, 1 minor, 5 nit. Verdict: request_changes. reached max rounds (1).

Required fixes

  • Delegate-object identity gate: install never runs for a new collection view when the same @​State PagerScrollDelegate is reused (weak installedCollectionView still resolves to the old one)

🟠 Major (1)

Where Finding
ios/PagerView.swift:57 Delegate-object identity gate: install never runs for a new collection view when the same @​State PagerScrollDelegate is reused (weak installedCollectionView still resolves to the old one)

🟡 Minor (1)

Where Finding
ios/PagerView.swift:63 Install-once gate keys only on collection-view identity, so a delegate replaced after install is never re-installed (regression vs. the self-healing weak-nil gate)
🔵 Nit (5)
Where Finding
ios/PagerView.swift:9 @State holds the newly created PagerScrollDelegate — the install-once state dies with the view
ios/PagerScrollDelegate.swift:100 responds(to:) answers false for a legitimately non-cyclic re-entrant query, silently disabling an optional delegate method
ios/PagerScrollDelegate.swift:116 responds(to:) guard has no counterpart in forwardingTarget(for:), so the SDK-proxy cycle it exists for is only half blocked
ios/PagerScrollDelegate.swift:100 isQueryingOriginalDelegate is a plain Bool mutated around originalDelegate?.responds(to:), but responds(to:) …
ios/PagerView.swift:57 Stale/contradictory comment: "VTabView-style rotation" forces .horizontal inside the install block

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: 7 new findings on bca73d1. The verdict and the full list are in the summary comment.

Comment thread ios/PagerView.swift
}

if scrollDelegate.originalDelegate == nil {
// Install once per collection view. This closure re-runs on every

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Major · Delegate-object identity gate: install never runs for a new collection view when the same @​State PagerScrollDelegate is reused (weak installedCollectionView still resolves to the old one)

Open ledger item, settled as a behaviour regression against the predecessor.

Suggested change
// Install once per collection view. This closure re-runs on every
// Adopt ourselves unless we are already this collection view's delegate:
// idempotent for the same view (no cycle), but still re-adopts after the
// displaced delegate died or when this @State delegate is handed a new
// collection view.
if collectionView.delegate !== scrollDelegate {
scrollDelegate.originalDelegate = collectionView.delegate
scrollDelegate.delegate = delegate
// VTabView-style rotation preserves TabView's horizontal collection view.
scrollDelegate.orientation = .horizontal
collectionView.delegate = scrollDelegate
}

Suggested change

Key the install decision on the collection view's current delegate (collectionView.delegate !== scrollDelegate) instead of the delegate's own weak memory identity, and cover the page-count-change-then-scroll case with a host-app/Maestro test asserting onPageScroll still fires.

Details

scrollDelegate is @State (ios/PagerView.swift:9), so it outlives the TabView identity churn from .id(props.children.count) (line 29) and can outlive re-creation of the hosting controller. The new gate compares the delegate's own weak var installedCollectionView with the collection view SwiftUI just handed to .introspect. Those are two independently-lived weak references: whenever the delegate object survives but SwiftUI installs its PagerView state onto a new collection view while the previous one is still alive, scrollDelegate.installedCollectionView still resolves to the old object and is !== collectionView… no — the failure is the mirror case: the delegate object is reused and installedCollectionView still points at a live collection view that happens to be the one being introspected again, so the block is skipped and scrollDelegate.delegate, orientation and originalDelegate are never set for the collection view that is actually on screen. In either ordering the predicate is keyed on the delegate's memory rather than on the collection view's current delegate, so nil cannot distinguish "installed into this view already" from "installed into a different view that is still alive" — the exact ambiguity the code comment objects to for originalDelegate == nil.

Concretely: the same @State delegate for a different collection view means no onPageScroll/onPageScrollStateChanged is ever emitted for that pager, and originalDelegate is never adopted so the internal SwiftUI page/index bookkeeping is bypassed. The predecessor's originalDelegate == nil gate did run in that case and did install. collectionView.delegate !== scrollDelegate is cycle-proof and does not have this ambiguity.

Also raised here

Also raised at this location by senior-developer: install is now keyed on delegate identity: a PagerScrollDelegate reused across collection views (or one whose displaced delegate died) never forwards to the live SwiftUI delegate again — The old gate (originalDelegate == nil) re-adopted collectionView.delegate on every layout pass once the weak reference died, which is what created the cycle. The new gate (scrollDelegate.installedCollectionView !== collectionView) skips the whole block for as long as installedCollectionView points at the object that was passed to introspect. That fixes the cycle but it also makes adoption one-shot per delegate object, keyed on a weak reference, and scrollDelegate is @State — i.e. it persists across .id(props.children.count) changes and across re-creation of PagerViewProvider/UIHostingController, while collectionView is whatever SwiftUI hands this still-live state object on the next .introspect call. Two reachable sequences where the new code loses forwarding that the predecessor kept: Displaced delegate dies, collection view survives. …

Also raised at this location by maintainability: Install-once gate no longer refreshes scrollDelegate.delegate for a reused collection view — scrollDelegate.delegate is a weak var, and it used to be re-assigned on every pass where originalDelegate had gone nil — which is exactly the state SwiftUI reaches after a page-count change rebuilds the TabView. Now the gate is installedCollectionView !== collectionView, so for a collection view that survives the rebuild the assignment scrollDelegate.delegate = delegate never runs again. If the PagerViewProviderDelegate instance handed to PagerView is not the same object across that rebuild (or is deallocated and recreated), scrollDelegate.delegate goes nil and onPageScroll/onPageSelected silently stop for that pager — a regression versus the old gate, which repaired it on the next layout pass. The install-once fix should cover only the collectionView.delegate adoption; keep refreshing the event delegate on every pass.

persona: senior-developer, maintainability

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.

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-staging pivot Tested on the simulator. Each page count change makes a new PagingCollectionView. installedCollectionView still points at the old one, so !== is true and the install runs. I've added logs which show the install on every new collection view. Your suggesttion brings the cycle back when an SDK proxy is the delegate, because the delegate is then never scrollDelegate.

Comment thread ios/PagerView.swift
// 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor · Install-once gate keys only on collection-view identity, so a delegate replaced after install is never re-installed (regression vs. the self-healing weak-nil gate)

The predecessor re-ran the install whenever scrollDelegate.originalDelegate == nil, which — including when the weak reference died on its own — meant the block kept re-adopting collectionView.delegate and so also repaired the installation after anything replaced the collection view's delegate behind our back: SwiftUI re-applying its own delegate on a re-render, or a swizzling SDK's proxy being torn down. The new gate scrollDelegate.installedCollectionView !== collectionView is false for the life of that collection view no matter what collectionView.delegate is now, so once something else takes the delegate the pager stops receiving scrollViewDidScroll/willDisplay/didEndDecelerating and never reinstalls: onPageScroll, onPageScrollStateChanged and onPageSelected stop firing for that pager while it is on screen, with no path back. installedCollectionView is weak, so even a dead-collection-view re-install relies on the weak read being nil; if the weak reference is still alive when SwiftUI hands back the same collection view, the install is skipped there too.

Suggested change
if scrollDelegate.installedCollectionView !== collectionView {
if scrollDelegate.installedCollectionView !== collectionView || collectionView.delegate !== scrollDelegate {
if collectionView.delegate !== scrollDelegate {
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
}

Suggested change

Make the gate "we are not currently installed" rather than "we installed into this view once": e.g. install when scrollDelegate.installedCollectionView !== collectionView || collectionView.delegate !== scrollDelegate, while still refusing to adopt a delegate that is this same PagerScrollDelegate (or one whose chain re-enters us) so the cycle cannot form. That keeps the fix and restores the predecessor's self-healing.

Details

persona: swift-ios

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.

Comment thread ios/PagerView.swift
@@ -54,11 +54,18 @@ struct PagerView: View {
collectionView.rightEdgeEffect.isHidden = true

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 · @State holds the newly created PagerScrollDelegate — the install-once state dies with the view

PagerView stores the delegate object in @State private var scrollDelegate = PagerScrollDelegate(). Per SwiftUI semantics, a reference type put in @State is never updated: the value is snapshotted the first time the view is created, and every later PagerView value for the same view identity keeps the initial object; the fresh PagerScrollDelegate() built for each new struct is thrown away. This matters directly for this PR's premise: the PR description states that .id(props.children.count) makes SwiftUI rebuild the TabView "when the page count changes", i.e. the PagerView subtree and its @State are recreated. A new PagerScrollDelegate (with installedCollectionView == nil) is then created and discarded, while the body keeps operating on the OLD object — the one whose installedCollectionView still points at the old, now-dead collection view. On every layout pass the gate scrollDelegate.installedCollectionView !== collectionView is then true for the live collection view, so the install block re-runs every single pass and re-adopts collectionView.delegate, which is now scrollDelegate itself. That sets originalDelegate = self, which didSet immediately nils out, so no cycle forms, but the TabView's own delegate is displaced and never re-captured: the displaced delegate's scrollViewDidScroll/willDisplay/collectionView(_:willDisplay:...) stop being forwarded. Worse, the same nil-ing means the cycle can re-form whenever collectionView.delegate is a proxy that forwards back to us rather than self — the exact case the PR says it fixes. The originalDelegate == nil gate accidentally re-adopted here too, so this is not a new crash, but the new "install once" guarantee — the headline fix — silently does not hold across the very scenario the PR cites (page-count change), because the marker lives in state that does not survive the rebuild.

Suggested change
collectionView.rightEdgeEffect.isHidden = true
@StateObject private var scrollDelegateHolder = PagerScrollDelegateHolder()

Suggested change

Do not key the install-once marker on state stored in @State of a value type that SwiftUI recreates. Either hold the PagerScrollDelegate where the PagerView struct's identity is not the lifetime boundary (e.g. create it once in PagerViewProvider, which already owns the hosting controller, and pass it in), or key the marker on the collection view itself (an associated object / collectionView.delegate is PagerScrollDelegate check) so "already installed" is answered by the object that outlives the view rebuild.

Details

Also raised here

Also raised at this location by senior-developer: originalDelegate = nil inside its own didSet fires the observer again and silently discards the delegate it was handed — Two things about the new didSet. First, the self-assignment inside it re-triggers didSet for originalDelegate (Swift only suppresses the willSet-style loop, not observer re-entry for a second assignment); the recursion stops only because nil === self is false, so the property observer runs twice for that write — harmless today, but the termination condition is implicit and a future edit that adds another normalisation step here re-enters it. Second, the case is handled by dropping to nil, which also throws away any usable delegate that happened to be assigned, so forwarding stops for the rest of that collection view's life with no signal that the install was self-referential (the install site in PagerView.swift has no idea). A guard that skips the write, plus an assertion/log in debug builds, would name the condition instead of swallowing it.

Also raised at this location by swift-ios: didSet on originalDelegate hides the self-assignment instead of rejecting it, and never repairs an already-formed cycle — if originalDelegate === self { originalDelegate = nil } inside didSet is a correction of the caller's own write, done as a silent side effect of a property setter: at ios/PagerView.swift:64 the install site writes collectionView.delegate into originalDelegate and the write is quietly dropped, with no indication that the caller adopted itself. The recursive didSet re-entry terminates (nil !== self), so it is not a bug in itself, but the guard is only correct because the property is weak and therefore nilled exactly once — nothing in the type enforces that. …

persona: senior-developer, swift-ios

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.

@@ -86,11 +98,25 @@ class PagerScrollDelegate: NSObject, UIScrollViewDelegate, UICollectionViewDeleg
}

override func responds(to aSelector: Selector!) -> Bool {

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 · responds(to:) answers false for a legitimately non-cyclic re-entrant query, silently disabling an optional delegate method

The guard is a single boolean, so it cannot tell a cycle from a legitimate nested query. Any chain where our responds(to:) is re-entered once while already querying — our own forwardingTarget(for:) path, an SDK proxy that queries back for a different selector, or a delegate that itself forwards responds(to:) — gets false for the inner query even though the object genuinely implements the selector. UIKit then treats an optional delegate/scroll-view method as unimplemented and skips it, so an event that should forward to the displaced delegate is dropped rather than the recursion being merely shortened. The flag is also reset by defer at the outermost frame only in the sense that the first frame resets it; an exception-free path is fine, but a nested query for selector A that returns false can be cached by UIKit's own conformance checks for the lifetime of that call.

Suggested change

Bound the recursion by depth and by selector instead of a flat boolean, so only the true cycle is cut: keep a per-selector in-progress set (or a small depth counter) and return false only when the same selector is being queried again on the same chain — the actual self-loop. Also consider clearing the flag from forwardingTarget(for:) so a forwarded message does not inherit a suppression meant for a different selector.

Details

Also raised here

Also raised at this location by senior-developer: Re-entrant responds(to:) returns false for selectors the original delegate does implement, silently dropping them — For any selector not in handledSelectors, a re-entrant query now returns false unconditionally — including the case where the chain is finite (e.g. self → P1 → P2 → self only through a getter, or a two-proxy chain that would have terminated). UIKit skips an optional delegate method that answers false, so on a swizzling-SDK app a delegate method the displaced delegate does implement is never forwarded, with no log and no way to tell that apart from the delegate genuinely not implementing it. The old expression had no such false-negative. This is the intended trade for not crashing, but it is a silent behaviour change on exactly the hosts the fix targets, and nothing in the repo exercises it.

persona: swift-ios, 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.

return originalDelegate?.responds(to: aSelector) ?? false
}

override func forwardingTarget(for aSelector: Selector!) -> Any? {

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 · responds(to:) guard has no counterpart in forwardingTarget(for:), so the SDK-proxy cycle it exists for is only half blocked

responds(to:) is now hardened against a cycle formed by an SDK proxy that forwards back to this object, but forwardingTarget(for:) only rejects the direct case (target === self). In the same scenario the guard was added for — originalDelegate is a proxy whose forwardingTarget(for:) hands the message back to this object — this method returns that proxy again for any selector outside handledSelectors, which is the same cycle, one level down. It is only blocked because UIKit happens to ask responds(to:) before sending an optional delegate message (and because the runtime refuses to chain forwardingTargetForSelector: forever and falls through to forwardInvocation:, i.e. doesNotRecognizeSelector). Any sender that does not check responds(to:) first — a swizzled setter path, or a non-optional message on the forwarded-to interface — takes the forwarding path instead, and the re-entrancy flag is never consulted there because it only wraps the responds(to:) query. The comment's assumption that this is safe rests on UIKit call discipline and on runtime behaviour, not on anything the code enforces; the guard and the forwarding path should use the same cycle test.

Suggested change
override func forwardingTarget(for aSelector: Selector!) -> Any? {
override func forwardingTarget(for aSelector: Selector!) -> Any? {
guard !handledSelectors.contains(aSelector) else { return nil }
// Same cycle the `responds(to:)` guard above breaks: a proxy that forwards
// back to us would be returned here again for every unhandled selector.
guard !isQueryingOriginalDelegate else { return nil }
let target = originalDelegate
return target === self ? nil : target
}

Suggested change

Make the cycle check symmetric: reject a forwarding target whose chain re-enters this object (or track the proxy case explicitly), rather than relying on callers checking responds(to:) before sending. Add a test for the proxy-with-real-forwardingTarget case, not only the proxy-that-only-forwards-responds(to:) case.

Details

Also raised here

Also raised at this location by maintainability: responds(to:) gained a re-entrancy guard for the SDK-proxy case, but the matching override below only breaks a *dir… — responds(to:) gained a re-entrancy guard for the SDK-proxy case, but the matching override below only breaks a direct self-cycle (target === self). The scenario the new comment describes — a swizzling SDK's proxy P held in originalDelegate that forwards back to this object — is not caught by === self, so a message send for an unhandled selector still walks us → P → us → P through forwardingTarget(for:) until the stack overflows; the responds(to:) guard does not stop it, because the forwarding machinery does not consult it per hop. If the intent is defense-in-depth against proxies, the same guard (or a hop counter) is needed here too; otherwise the comment above responds(to:) overstates what is protected.

persona: senior-developer, maintainability

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.

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-staging note Confirmed on the device, and the severity may be more than a nit. With proxy that forwards back to this object, the PR crashes on the first layout. The loop goes through the handled methods (collectionView(_:willDisplay:forItemAt:) calls originalDelegate directly), not through forwardingTarget(for:). The guard must cover every forward to originalDelegate.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@@ -86,11 +98,25 @@ class PagerScrollDelegate: NSObject, UIScrollViewDelegate, UICollectionViewDeleg
}

override func responds(to aSelector: Selector!) -> Bool {

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 · isQueryingOriginalDelegate is a plain Bool mutated around originalDelegate?.responds(to:), but responds(to:) …

isQueryingOriginalDelegate is a plain Bool mutated around originalDelegate?.responds(to:), but responds(to:) is Objective-C runtime introspection, not a delegate callback — it is invoked by respondsToSelector: from anywhere, including the SDKs named in the comment (which enumerate/set up on background queues) and KVO/performSelector: paths. The comment's justification, "Delegate callbacks are main-thread only," does not cover this method, so two concurrent respondsToSelector: queries (main thread inside the query, a second thread starting one) make the second one take the guard and answer false for a selector the displaced delegate does implement — a legitimate optional UICollectionViewDelegate/UIScrollViewDelegate method is then skipped on that send, and the unsynchronised read/write is a data race. Either assert/require the main thread here (Thread.isMainThread, falling back to answering the query directly off-main) or state the main-thread precondition on the property.

Suggested change
override func responds(to aSelector: Selector!) -> Bool {
// 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`. The flag is only safe because delegate
// queries arrive on the main thread.
guard Thread.isMainThread, !isQueryingOriginalDelegate else { return originalDelegate?.responds(to: aSelector) ?? false }
Details

persona: maintainability

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.

Comment thread ios/PagerView.swift
}

if scrollDelegate.originalDelegate == nil {
// Install once per collection view. This closure re-runs on every

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 · Stale/contradictory comment: "VTabView-style rotation" forces .horizontal inside the install block

The install block sets scrollDelegate.orientation = .horizontal under the comment "VTabView-style rotation preserves TabView's horizontal collection view", but 8 lines above the same block honours orientation == "vertical" with a vertical rotation (and hides the right edge effect). On that vertical path the comment is false and the line overwrites the orientation the delegate needs, so onPageScroll/onPageSelected would report the wrong axis for a vertical pager whose install actually happens on that pass. Move the assignment out of the install-once gate (it is not per-collection-view state) or, if forcing horizontal really is right for every pass, drop the now-contradictory comment and make the unconditional assignment explicit.

Suggested change
// Install once per collection view. This closure re-runs on every
if scrollDelegate.installedCollectionView !== collectionView {
scrollDelegate.originalDelegate = collectionView.delegate
scrollDelegate.delegate = delegate
scrollDelegate.installedCollectionView = collectionView
collectionView.delegate = scrollDelegate
}
// VTabView-style rotation preserves TabView's horizontal collection view.
scrollDelegate.orientation = .horizontal
Details

persona: maintainability

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.

@jacekAtCK jacekAtCK 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.

Thanks for the detailed write-up. I tested this PR on the iPhone 15 simulator ios 18.0.

Without an SDK, I could not reproduce the crash in the app. I did something like 10 rounds of add and remove page, and swipe on base and i did not observe any crashes.
Each page-count change makes a new PagingCollectionView with a new SwiftUI Coordinator, and the old collection view is not laid out again after its coordinator is freed. So the self-cycle does not form.
Can you share your crash reproduction steps?

With an SDK-style proxy, the PR still crashes.As a test i've added -[UICollectionView setDelegate:]. It keeps one proxy for each collection view and points it at each new delegate.

I'm very rusty in swift so I've let an agent figure out a possible fix (untested):
guard re-entry around every forward to originalDelegate, not only in responds(to:).

Videos of both crashes are attached.

https://github.com/user-attachments/assets/e3233291-5c44-44a4-a07f-4c7c5663307f
https://github.com/user-attachments/assets/5cc25e9d-ebcf-4c76-94b0-aad9030ec065

@jacekAtCK jacekAtCK 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.

after thorough testing it seems that:

  • this is a partial fix
  • no regressions introduced

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

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

3 participants