Skip to content

Don't hold the delegate when UIKit refuses to show the Customer Center - #539

Merged
yusuftor merged 2 commits into
developfrom
fix/customer-center-refused-present
Oct 7, 2026
Merged

yusuftor merged 2 commits into
developfrom
fix/customer-center-refused-present

Conversation

@yusuftor

@yusuftor yusuftor commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

What

CustomerCenterManager.present stored the delegate, the controller and the presentation count before calling present(_:animated:). UIKit refuses some presentations, for example from a view controller that isn't in a window or is mid-transition, and only logs a warning when it does. In that case the manager kept the app's delegate alive with nothing on screen, until the next present replaced it.

It now calls present first and only holds on to anything once controller.presentingViewController is set. Otherwise it logs an error and returns.

Greptile flagged this on #532 as "later presents are blocked". That part doesn't happen: presentedController is weak, so a refused controller is freed straight away and the next present still works. The new test checks both.

onDismiss for a refused presentation: it isn't called. Its docs say "Called after the Customer Center is dismissed", and a screen that never appeared wasn't dismissed. customerCenterDidDismiss() doesn't fire either, for the same reason.

No CHANGELOG entry: the Customer Center hasn't been released yet (master is 4.17.0).

Tests

Added refusedPresentationReleasesDelegate to CustomerCenterManagerTests. It presents from a view controller with no window, then checks the delegate is released, nothing is counted as presented, onDismiss didn't run, and a following present from a real window still works. It failed on the old code (delegate kept, presentation counted) and passes now. CustomerCenterManagerTests and CustomerCenterSheetOwnershipTests all pass (41 tests).

Checklist

  • All unit tests pass. (The Customer Center suites; CI runs the full suite.)
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on Mac Catalyst.
  • Demo project builds and runs on visionOS.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes. (Not needed: unreleased feature.)
  • I have run swiftlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

🤖 Generated with Claude Code

RetriggerConfidence Score: 5/5

This PR appears safe to merge.

What we checked:

  • Refused screens keep the delegate: present returns before assigning retainedDelegate when the controller has no presenter. The adapter holds only weak references.

Summary

CustomerCenterManager.present now asks UIKit to present before retaining the delegate or recording the presentation. If UIKit refuses, it logs an error and returns.

  • Adds a test for delegate release, unchanged presentation count, no dismissal callback, and a successful retry.
  • No actionable issues found.
  • yusuftor explicitly omitted the changelog entry because Customer Center is unreleased.

Reviews (1) · Last reviewed commit: "Don't hold the delegate when UIKit refus..." · Reviewed by Greptile

UIKit refuses some presentations, such as from a view controller that isn't
in a window, and only logs it. The manager stored the delegate and counted
the presentation before asking, so the app's delegate stayed alive with
nothing on screen. It now checks that UIKit took the controller first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@maple-review-bot

maple-review-bot Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 9/10 · safe to merge
The ownership change is contained, follows existing logging conventions, and has a regression test covering refusal and retry.
quality 100/100 · no findings · tests covered · risk medium · 1/1 new units observable

Customer Center presentation now retains its delegate and updates presentation state only after UIKit accepts the controller. The regression test covers delegate release, skipped dismissal callbacks, and a successful subsequent presentation; the change is safe to merge.

What was checked
  • Both Swift and Objective-C presentation paths share the updated ownership logic.
  • Delegate adapter references are weak; dismissal delivers callbacks before releasing manager ownership.
  • The new Swift Testing regression asserts refusal cleanup and successful retry from a window.
Observability coverage: 1 of 1 changes observable
Change Kind Observable Evidence
CustomerCenterManager.present refusal handling error path yes The rejected presentation logs an error through the existing Logger with customerCenter scope; no new asynchronous or outbound work.

f7703c4 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

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

ℹ️ No issues with the fix. One stale comment outside the diff is worth a look.

Reviewed changes

I reviewed the reordering in CustomerCenterManager.present and the new regression test.

  • Detect refused presentations: present(_:animated:) now runs first. The manager keeps retainedDelegate and presentedController and bumps presentCount only when UIKit actually set controller.presentingViewController. Otherwise it logs an error and returns.
  • Regression test: refusedPresentationReleasesDelegate presents from a detached controller and checks that the delegate is released, nothing is counted, and onDismiss doesn't fire. It then confirms a later windowed present works. The test really pins the bug: on the old code weakDelegate stays non-nil and presentCount is 1, so both assertions fail.

The delegate can't be freed partway through the call. While present runs, the delegate parameter holds it strongly, so any callback that fires during the synchronous part of the presentation still reaches it before retainedDelegate is assigned. A refused controller only reaches onDismiss through deliverDismissal(). That runs from viewDidDisappear/didMove(toParent: nil), and neither fires for a controller that was never shown. So skipping onDismiss holds up in practice, not just by the docs.

ℹ️ Nitpicks

  • The doc comment on wasPresentedModally in CustomerCenterViewController.swift#L49-L51 says a hostless test target "never populates presentingViewController". This PR depends on the opposite. Both singleInstance (animated) and the new test expect isPresented right after present, and that now requires presentingViewController to be set synchronously. In the test target it's viewDidAppear that never runs, not presentingViewController that stays unset. The comment should say so, so it doesn't contradict the manager's new check.

Pullfrog  | Fix it ➔ | View workflow run | Using claude-opus-5.5 | 𝕏

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@maple-review-bot

maple-review-bot Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 10/10 · safe to merge
The sole change since the prior review is a comment correction with no executable code changes.
quality 100/100 · no findings · tests not needed · risk low

The follow-up corrects the wasPresentedModally comment to distinguish presentation start from viewDidAppear. It changes no runtime behavior and is safe to merge.

What was checked
  • The only hunk changed since the prior review edits documentation, not wasPresentedModally logic.

343863c · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@yusuftor
yusuftor merged commit 929d248 into develop Oct 7, 2026
6 checks passed
@yusuftor
yusuftor deleted the fix/customer-center-refused-present branch October 7, 2026 14:31
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.

1 participant