Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,20 @@ final class CustomerCenterManager {
self?.retainedDelegate = nil
onDismiss?()
}
presenting.present(controller, animated: presentsAnimated)
// UIKit refuses some presentations (the presenter isn't in a window, or is mid-transition)
// and only logs it, so check that it took the controller before holding on to anything.
if controller.presentingViewController == nil {
Logger.debug(
logLevel: .error,
scope: .customerCenter,
message: "The Customer Center couldn't be presented from \(type(of: presenting))."
)
return
}
retainedDelegate = delegate
presentedController = controller
presentCount += 1
presenting.present(controller, animated: presentsAnimated)
}

/// Dismisses the presented Customer Center, if any.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ public final class CustomerCenterViewController: UIHostingController<CustomerCen
/// ``isLeavingHierarchy``.
///
/// Internal rather than private only so tests can set it: a hostless test target never drives a
/// modal transition to completion, so UIKit never populates `presentingViewController` there and
/// this can't be reached through a real presentation.
/// modal transition to completion, so `viewDidAppear` never runs there and this can't be set
/// through a real presentation. UIKit does still set `presentingViewController` as soon as the
/// presentation starts.
var wasPresentedModally = false

/// Latches ``deliverDismissal()``; see its note on the overlapping call sites.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,48 @@ struct CustomerCenterManagerTests {
window.isHidden = true
}

@Test("a presentation UIKit refuses doesn't keep the delegate, and the next present works")
func refusedPresentationReleasesDelegate() {
final class ProbeDelegate: CustomerCenterDelegate {}

let container = DependencyContainer()
let manager = CustomerCenterManager(container: container)
manager.presentsAnimated = false
// Its view isn't in a window, so UIKit refuses to present from it.
let detachedPresenter = UIViewController()

var strongDelegate: ProbeDelegate? = ProbeDelegate()
weak var weakDelegate = strongDelegate
var dismissCount = 0

manager.present(
configuration: nil,
from: detachedPresenter,
delegate: strongDelegate,
onDismiss: { dismissCount += 1 }
)
strongDelegate = nil
spinRunLoop(timeout: 1) { weakDelegate == nil }

#expect(detachedPresenter.presentedViewController == nil)
#expect(!manager.isPresented)
#expect(manager.presentCount == 0)
#expect(weakDelegate == nil)
// Nothing was shown, so nothing was dismissed.
#expect(dismissCount == 0)

let presenter = UIViewController()
let window = makeTestWindow(rootViewController: presenter)
window.makeKeyAndVisible()
spinRunLoop(timeout: 1) { presenter.viewIfLoaded?.window != nil }

manager.present(configuration: nil, from: presenter, delegate: nil, onDismiss: nil)
#expect(manager.isPresented)
#expect(manager.presentCount == 1)

window.isHidden = true
}

@Test("viewDidDisappear fires didDismiss while the delegate is still retained")
func viewDidDisappearFiresDelegateBeforeRelease() {
final class ProbeDelegate: CustomerCenterDelegate {
Expand Down
Loading