Skip to content

Customer Center - #509

Open
DreamingInBinary wants to merge 35 commits into
developfrom
customer-management-portal
Open

Customer Center#509
DreamingInBinary wants to merge 35 commits into
developfrom
customer-management-portal

Conversation

@DreamingInBinary

Copy link
Copy Markdown
Contributor

Changes in this pull request

Adds the Customer Center: a native, self-service subscription-management screen inside the SDK. One call presents it:

Superwall.shared.presentCustomerCenter()

It shows the customer's subscriptions and purchases and lets them restore purchases, open Apple's manage-subscriptions sheet, request a refund, change plans, contact support, answer an exit survey, and browse purchase history. There's a SwiftUI view (CustomerCenterView), a UIKit view controller (CustomerCenterViewController), an Objective-C surface, a delegate, five SwiftUI callback modifiers, five new analytics events, and strings for all 41 locales.

Everything is configured in code via SuperwallOptions.customerCenter. The configuration model is Codable and deliberately shaped so a future dashboard/backend can serve the same JSON without changing the public API — resolution order is per-call argument → options → .default.

Zero-config gives a working screen: with an active App Store subscription you get the subscription card, Restore, Change plan, Request a refund, Cancel subscription (with a cancellation survey), See all purchases, and Account details. The only row that needs configuration is Contact support, which is hidden unless a support email is set.

Requires iOS 15+. The SDK's deployment target is unchanged at iOS 13 — the Customer Center symbols are @available(iOS 15.0, *), because every StoreKit API it drives is iOS 15+ anyway.

Reviewing this

108 files is a lot, but five files are the whole feature — the rest is SwiftUI, tests, and localization:

  1. CustomerCenter/Models/CustomerCenterConfiguration.swift — the entire public surface. Start here.
  2. Superwall+CustomerCenter.swift — the entry point (~100 lines).
  3. CustomerCenter/ViewModel/CustomerCenterViewModel.swift — state, flows, event emission.
  4. CustomerCenter/Logic/CustomerCenterPathResolver.swift — which actions appear when. This is the product logic.
  5. CustomerCenter/Logic/PurchasePresentationBuilder.swift — badges, status lines, renewal dedupe.

For 4 and 5, the table-driven tests read like a spec and are the fastest way in. Alternatively the commits are in dependency order, tests first, one concept each: git log --reverse --patch <base>..HEAD -- Sources/SuperwallKit/CustomerCenter.

Changes outside CustomerCenter/ are all small, necessary hooks: SuperwallOptions (+2 lines), LogScope (+1 case), DeviceHelper (+1 internal accessor), DependencyContainer (lazy @MainActor manager), the three analytics files (5 new event cases, purely additive), SuperwallKit.md, and one defaulted parameter on a shared test fixture. TransactionManager gains a presentsFailureAlert flag defaulting to true, so paywall restore behaviour is bit-identical.

Deliberately out of scope

Promotional / win-back retention offers (they need server-side signature generation), remote dashboard configuration, support tickets, virtual currencies, and the Android / Flutter / React Native bridges.

Decisions worth a second opinion

  • Version stayed at 4.16.4. develop was already ahead of master (4.16.3), so per CLAUDE.md the CHANGELOG entries went into the existing staged section rather than bumping again. New public API arguably warrants 4.17.0 — reviewer's call; it's a three-file change.
  • "Customer Center" is also RevenueCat's product name, chosen for discoverability and migration parity. Verified there are no symbol clashes: our Objective-C classes are SWK-prefixed against their RC-prefixed ones, so no duplicate class registration. One real collision was found and fixed — both SDKs put presentCustomerCenter on SwiftUI's View with everything after isPresented defaulted, and Swift silently resolved the bare call to ours (its solver penalises each defaulted argument it fills; ours fills 2, theirs 13), which would have hijacked an existing RevenueCat customer's screen with no error. Our modifier is now presentSuperwallCustomerCenter. Verified by building a target that imports SuperwallKit, RevenueCat and RevenueCatUI together and demangling the linked symbols.
  • Refund stays available on expired subscriptions. Apple permits it, but it's a product call.
  • "Cancel subscription", not "Manage subscription". In the default configuration that row carries the cancellation survey and opens Apple's cancel sheet, so the old label overstated it. Each locale uses its subscription-termination verb (German kündigen, French résilier, Japanese 解約) rather than the dialog-dismiss word.
  • The 41 locale translations are first-draft with no native-speaker review. Worth routing through localization before release.

Known gaps

  • The internal navigation flag was replaced with a visibility count, which closed the embedded-mode dismissal hole. Row ordering is nondeterministic when two products tie on both active-ness and expiry date (contents are deterministic). Support/Appearance/ColorPair override isEqual without hash, matching existing convention in CustomerInfo and friends.
  • dismiss(completion:)'s UIKit-driven completion path and the SDK's alert-suppression have no automated coverage: the hostless test target cannot complete modal presentations or present a UIAlertController, so such assertions would pass whether or not the code works. Both were verified manually instead.

Testing

1006 tests across 110 suites, all passing. Every task was reviewed by someone other than its author, with a cross-cutting review over the whole branch.

A full manual pass was also run on device across 20 scenarios — purchase, cancel, refund, expiry, billing retry, empty state, restore, delegate callbacks, code-driven configuration — and it found two real bugs that static review did not:

  • Apple's manage-subscriptions sheet never appeared after the cancellation survey. ManageSubscriptionsSheet branched on groupId, which turned non-nil in the same update that flipped isPresented true; SwiftUI tore down the modifier that was about to present. Now branches only on #available.
  • Restoring with no purchases showed two stacked alerts — the SDK's paywall-worded failure alert on top of the Customer Center's own.

Also fixed from that pass: disclosure chevrons were removed from action rows (a chevron promises a push, and none of those rows push), and the update banner now animates out instead of blinking.

Checklist

  • All unit tests pass. (1006 tests / 110 suites)
  • All UI tests pass. — N/A, this repo has no UI test target.
  • Demo project builds and runs on iOS. (Basic and Advanced; manually exercised on iPhone 17 simulator)
  • Demo project builds and runs on Mac Catalyst. (framework builds for Catalyst; the Customer Center is #if os(iOS) and available on Catalyst 15+)
  • Demo project builds and runs on visionOS. — not verified; CI doesn't build visionOS.
  • I added/updated tests or detailed why my change isn't tested. (See "Known gaps" for the two paths the hostless test target cannot cover.)
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues. (10 violations, all pre-existing on develop; zero added.)
  • I have updated the SDK documentation as well as the online docs. — DocC article added (Documentation.docc/CustomerCenter.md) and linked from SuperwallKit.md. The online docs page still needs writing.
  • I have reviewed the contributing guide

cc @yusuftor @jakemor @anglinb

DreamingInBinary and others added 29 commits August 20, 2026 13:24
Adds SuperwallEvent.customerCenterOpen/Close/Action/SurveyResponse/RefundRequest
with ObjC mirrors and InternalSuperwallEvent trackable structs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the Customer Center's 74 string keys (screens, paths, survey,
purchase status, badges, stores, sections, restore, refund, update
warning, duplicate subscriptions, and support) to all 41 Localizable.strings
bundles, plus the bundle-backed CustomerCenterStrings.bundled(locale:).

Also folds in two items deferred from Task 6's review: a dedicated
customer_center_expired key so an inactive subscription with no
expiration date shows "Expired" instead of "Refunded", and a
regression test for nil-expiration sort ordering.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e and restore views

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tCustomerCenter

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… actor

DependencyContainer.init constructed CustomerCenterManager via
MainActor.assumeIsolated at the end of init, but init itself isn't
@mainactor. ~20 test suites (and any host app calling Superwall.configure
off-main) construct DependencyContainer off the main thread, crashing with
EXC_BREAKPOINT. Fixed by deferring construction to the customerCenterManager
accessor itself, now marked @mainactor and built lazily on first access; all
production call sites (Superwall.presentCustomerCenter/dismissCustomerCenter/
the Objective-C variant) are already @mainactor, so this needs no
assumeIsolated.

Also logs a loud warning from CustomerCenterManager.makeViewModel(configuration:)
when Superwall hasn't been configured yet, since CustomerCenterView/
CustomerCenterViewController route through it and would otherwise silently
render a dead screen with no purchase data.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a Customer Center button to the Basic and Advanced example apps,
a CustomerCenter.md DocC article, and CHANGELOG entries under the
already-staged 4.16.4 release (develop is ahead of master, so no
version bump is needed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…doff, embedded dismiss, receipt refresh, ObjC parity

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The screen shows when the customer has no purchases on record at all — no
subscriptions (active or expired), no one-time purchases, no active
entitlements. An expired subscriber routes to the management screen, so
"no active" described a case that never reaches here. The new name matches
the hasAnyPurchases predicate that actually gates it.

Renames the public noActiveScreen property, the internal screen state case,
NoPurchasesScreenView, the customerCenterOpen event's screen value, the
accessibility identifier, and the localization keys across all 41 locales
(keys only — the displayed copy is unchanged).
The final review pass rewrote "Created by Claude" to "Created by Jordan
Morgan" across the whole repo when it should have been scoped to the files
this feature adds. That touched 24 pre-existing files (TestMode,
V2ProductsResponse, TestStoreUser, EntitlementProcessor and several test
files) that have nothing to do with the Customer Center. Restores them to
their state on develop; the header fix stands only on Customer Center files.
…CustomerCenter

RevenueCatUI puts a presentCustomerCenter modifier on View with every
parameter after isPresented defaulted, and so did we. Verified empirically by
building a target that imports SuperwallKit, RevenueCat and RevenueCatUI: with
the shared name, a bare .presentCustomerCenter(isPresented:) call compiled
without error and silently resolved to SuperwallKit's — Swift's solver
penalises each defaulted argument it fills, and ours fills 2 against
RevenueCat's 13. An existing RevenueCat customer adding SuperwallKit would have
had their Customer Center silently swapped for ours, with no diagnostic.

Renaming the modifier makes each resolve to its own module. Confirmed by
demangling the linked symbols: presentCustomerCenter -> RevenueCatUI,
presentSuperwallCustomerCenter -> SuperwallKit.

Objective-C was already safe (RC* vs SWK* prefixes, so no duplicate class
registration at load, which @available could not have prevented). The four
shared Swift type names (CustomerCenterView, CustomerCenterViewController,
CustomerCenterNavigationOptions, CustomerCenterAction) stay as they are —
module qualification resolves those, and it is idiomatic Swift.

Superwall.shared.presentCustomerCenter() is unchanged; it is on our own type
and cannot collide.
In the default configuration, the .manageSubscription path carries the
cancellation survey and leads to Apple's manage-subscriptions sheet, so
its job is cancelling, not general management. "Manage subscription"
overstated what the row does.

The key customer_center_path_manage_subscription is unchanged since it
tracks the PathType.manageSubscription case, not the displayed text —
only the string values change, across englishStrings and all 41
Localizable.strings locales.

Each locale uses its subscription-termination verb (e.g. German
"kündigen", French "résilier", Japanese "解約", Dutch "opzeggen",
Italian "disdire", Croatian "otkazati", Danish/Norwegian "si/sei opp")
rather than reusing customer_center_cancel's dialog-dismiss word, except
where a language genuinely shares one verb for both senses (e.g.
Spanish, Portuguese, Polish, Czech, Vietnamese, Thai, Korean, Chinese),
confirmed against each file's existing register.
The ManageSubscriptionsSheet modifier chose its branch on `groupId`, which is
derived from viewModel.sheet and therefore turns non-nil in the same update
that flips isPresented to true. SwiftUI treats the two branches as different
view identities, so that update tore down the modifier that was about to
present and built a different one — Apple's sheet never appeared. Reported
from a device run: answering the cancellation survey dismissed the survey and
returned to the Customer Center with nothing else shown.

Branch on #available only, which is constant for the process, and pass the
group id through as a value. The sheet is never presented while groupId is
nil, so the empty-string fallback is unreachable in practice.

Not coverable by the existing tests: the view model already asserts the state
transition (sheet == .manageSubscriptions after the survey dismissal), and it
still passes — the failure was entirely in the SwiftUI presentation layer,
which the hostless test target cannot exercise.
A chevron promises a push onto the navigation stack. None of the action rows
push: restore runs in place, cancel/change plan/refund/custom URL present
sheets, and contact support leaves the app. The rows that genuinely push —
"See all purchases" and the purchase detail rows — are NavigationLinks and
draw their own chevron, so those are unaffected.

The rows still read as tappable from the accent-coloured label, matching how
action rows look elsewhere in iOS. The in-row progress indicator is kept.
…stomer Center's

Restoring from the Customer Center with no purchases showed two stacked
alerts: the SDK's paywall-worded restore-failure alert ("No Subscription
Found") on top of the Customer Center's own result alert ("No past
purchases", which is localized and offers Contact support).

tryToRestore gains a presentsFailureAlert flag, defaulting to true so the
public restorePurchases() and all paywall restores are unchanged. The
Customer Center passes false and keeps presenting its own outcome.

No automated coverage: the SDK presents that alert on the top-most view
controller via the key window, which the hostless test target has no way to
provide, so an assertion that no alert appears passes whether or not the fix
works. Verified against the reported device repro instead.
Tapping Continue flipped the flag outside a transaction, so the banner's
section vanished from the list in a single frame. Wrap the change in
withAnimation at the view layer, so removing the section from the list is part
of the same transaction. Reduce Motion gets withAnimation(nil), which applies
the change without animating.

Also adds a round-trip test for the appearance accent: a UIColor passed to
ColorPair is stored as hex and has to parse back into a Color for the theme to
tint anything. Nothing covered that path before.
…e root view

The root view's `.onDisappear` fired `dismiss()` directly, gated by an
`isNavigatingWithinCustomerCenter` flag set/cleared by pushed screens'
onAppear/onDisappear. In embedded mode (`usesExistingNavigation`) the host
owns the navigation stack, so if it tears its stack down while a pushed
screen (purchase detail / purchase history) is on top — popping to root,
resetting a NavigationPath, or a long-press-Back past the Customer Center —
the root view never reappears and the flag never clears. `didDismiss` and
`customerCenterClose` then never fire at all. The flag was also inaccurate
two pushes deep: history → purchase detail cleared it while still inside.

Replaced the boolean with a visibility count on the view model:
`surfaceDidAppear()`/`surfaceDidDisappear()` increment/decrement a counter,
attached to every surface that can be on screen (root, purchase detail
screen, purchase history, purchase detail rows — not sheets, since those
present over a root that stays alive). When the count reaches zero it
debounces briefly (default 0.3s, cancellable) before calling `dismiss()`,
because a push/pop transition can briefly have both or neither surface on
screen — one runloop turn isn't enough to tell "navigating within the
Customer Center" from "actually gone". `dismiss()` keeps its `didDismiss`
latch, so double-firing stays impossible regardless of how many surfaces
disappear.

Sheet mode and the UIKit CustomerCenterViewController are unaffected: the
root view still appears/disappears exactly once for those, so `didDismiss`
still fires exactly once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review flagged 0.3s as uncomfortably close to a UINavigationController
push/pop (~0.35s). During a pop the outgoing screen's onDisappear can land
before the root's onAppear, dipping the visible-surface count to zero
mid-transition; if the debounce elapses in that window, didDismiss fires while
the user is still inside the Customer Center. 0.6s clears it with margin.

The interval only delays how soon didDismiss reaches the host, and nothing is
gated on it. Tests inject a short interval, so they are unaffected.
@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown

PR author is not in the allowed authors list.

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

Important

The dismissal path looks broken for the primary presentation API: customerCenterDidDismiss() and the customerCenterClose event should never reach a delegate created the way the shipped example and the docs recommend. Details inline on CustomerCenterManager.swift.

Reviewed changes — full read of the 108-file diff at 2001d65, with the feature's five load-bearing files (config model, entry point, view model, path resolver, presentation builder) traced against their call sites and tests.

  • Customer Center feature — a native @available(iOS 15.0, *) self-service screen presented via Superwall.shared.presentCustomerCenter(), CustomerCenterView, or CustomerCenterViewController, covering restore, manage/cancel, refund, change plan, contact support, exit surveys and purchase history.
  • Public configuration surfaceCustomerCenterConfiguration and its nested Screen/Path/FeedbackSurvey/Support/Appearance types, Codable and ObjC-bridged, resolved per-call → SuperwallOptions.customerCenter.default.
  • Product logicCustomerCenterPathResolver (which rows appear, gated on store, active-ness, revocation, family sharing, refund window, iOS 17 change-plan availability) and PurchasePresentationBuilder (badges, status lines, per-product renewal dedupe), both table-tested.
  • Delegate + callbacksCustomerCenterDelegate, an ObjC mirror, a weak-holding adapter, and five SwiftUI .onCustomerCenter* modifiers accumulated through an environment box.
  • Analytics — five new SuperwallEvent cases with ObjC mirrors and parameter payloads.
  • Localization — 75 customer_center_* keys across all 41 .lproj bundles.
  • Hooks outside the featureSuperwallOptions.customerCenter, LogScope.customerCenter, DeviceHelper.appInstallDateValue, a lazy @MainActor DependencyContainer.customerCenterManager, and TransactionManager.tryToRestore(_:presentsFailureAlert:) defaulting to true.

A few things I checked and found clean, so they need no further attention: none of the five new events can implicitly trigger a paywall (canImplicitlyTriggerPaywall falls through to false); SuperwallOptions.encode(to:)'s explicit CodingKeys omits customerCenter, so nothing new is sent to the backend; every locale has all 75 keys with matching %@ counts and no stray %, so there is no String(format:) crash or argument-reordering hazard; and every StoreKit/SwiftUI API used is available at or below the version its guard asserts (verified against Apple's DocC JSON, including Mac Catalyst).

⚠️ Nothing exercises the seam between CustomerCenterManager and the view model's dismissal

The suite is genuinely good — the resolver and builder tables read like a spec, and the visibility-count tests pin the counter arithmetic exactly. But every dismissal test drives the view model directly with a live instance in hand, and CustomerCenterManagerTests separately asserts the delegate is released the instant the manager's cleanup runs. No test spans both, which is precisely why the inline CustomerCenterManager.swift finding passes CI today.

Technical details
# Add coverage for the manager → view model dismissal handoff

## Affected sites
- `Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift:331-400` — drives `surfaceDidAppear`/`surfaceDidDisappear` on a view model the test itself retains; the debounce always finds a live `self`.
- `Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift:46-88` — asserts `weakDelegate == nil` immediately after `onDismiss` runs, with no view model in play.
- `Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterDelegateAdapterTests.swift:33-47` — holds a strong local delegate for the whole test, so the adapter's `weak` capture is never observed going nil.

## Required outcome
- One test that presents through `CustomerCenterManager`, triggers the same dismissal cleanup the VC's `viewDidDisappear` would, waits past `dismissDebounceInterval`, and asserts the delegate's `customerCenterDidDismiss()` actually ran and a `customerCenterClose` event was tracked.
- The test must fail against the current code.

## Suggested approach (optional)
- Inject a short `dismissDebounceInterval` into the view model the manager builds (a test hook alongside `presentsAnimated`), keep a strong local reference to the probe delegate so the assertion is about delivery rather than lifetime, and assert on a mock tracker.

## Open questions for the human
- Is the hostless test target able to reach `viewDidDisappear` at all, or does this need to go through `presentedControllerForTesting?.onDismiss?()` plus an explicit `surfaceDidDisappear()` to simulate SwiftUI's teardown?

ℹ️ Two decisions the diff raises but can't settle

  • Version stayed at 4.16.4. Following CLAUDE.md literally is correct here (a release was already staged on develop), but this adds a substantial new public API surface — CustomerCenterConfiguration, CustomerCenterDelegate, CustomerCenterView, CustomerCenterViewController, five SuperwallEvent cases, a View modifier — under a patch version. Worth an explicit maintainer decision rather than falling out of the changelog rule.
  • 41 first-draft translations. Key parity and format specifiers are clean across every .lproj (I checked all 75 keys in all 41 files), so there's no correctness hazard; the remaining risk is purely wording, and the author's suggestion to route it through localization before release seems right.

ℹ️ Nitpicks

  • Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift:44ForEach(others.prefix(2)) silently truncates non-subscription purchases. With showsPurchaseHistory == false there's no "See all purchases" row, so anything past the first two becomes unreachable.
  • Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift:319 — comment says "the production default, 0.3s"; the default is now 0.6 (CustomerCenterViewModel.swift:67).

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift
Comment thread SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme Outdated
Comment thread Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift Outdated
Comment thread Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift Outdated
@linear-code

linear-code Bot commented Aug 21, 2026

Copy link
Copy Markdown

SW-5650

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

ℹ️ Nothing new to flag — the delta since the last review is a clean release bump. Not approving only because the prior review's five inline findings are still open and untouched by this commit.

Reviewed changes — the single commit since the prior pullfrog review at 2001d65 is chore(release): bump version to 4.17.0, touching three files.

  • Bumped the SDK version to 4.17.0Sources/SuperwallKit/Misc/Constants.swift:21, SuperwallKit.podspec's s.version and the CHANGELOG heading all moved together, which is the three-site update CLAUDE.md requires. No stale 4.16.x string remains anywhere outside CHANGELOG.md's own history, and Package.swift carries no SDK version to keep in sync.
  • Folded the staged ## 4.16.4 section into ## 4.17.0 — the two fix entries that were sitting under 4.16.4 (the cold-launch inactive fix and the web-entitlement fix) now ship under 4.17.0, and both are still present.

I checked the one thing this could plausibly have broken: 4.16.4 was never released — the newest GitHub release and master's Constants.swift are both 4.16.3 — so renaming that heading rolls unreleased entries forward rather than erasing a shipped version's changelog. Departing from CLAUDE.md's "develop is already ahead of master, do not bump again" rule is the right reading for a purely additive public API surface, and it settles the versioning question the prior review left to a maintainer.

Pullfrog  | Fix it ➔View workflow run | Using Claude Opus𝕏

yusuftor and others added 2 commits August 24, 2026 15:39
…nostics, hashes

- viewDidDisappear fires the view model's dismissal before onDismiss
  releases the retained delegate, and the dismissal debounce captures the
  model strongly so SwiftUI sheet teardown can't drop didDismiss or the
  close event
- date formatters follow the SDK's preferred locale, not the system's
- support email diagnostics list active entitlement ids, not product ids
- Support, Appearance and ColorPair hash by value, matching isEqual

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parallelizable: true now lives in project.yml so regeneration keeps it.
Defaulted Cache instances get a unique on-disk namespace under the test
runner so concurrently running tests stop contaminating each other's
storage; timing-sensitive tests poll for outcomes instead of assuming a
fixed sleep beats main-actor congestion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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 blocking issues. All five prior findings are genuinely addressed, and the two dismissal regression tests fail against the old code. Minor suggestions inline.

Reviewed changes — the two commits since the prior pullfrog review at 8c763af: 97702aa (fixes for the five inline findings) and 7ab4a98 (parallel test execution).

  • Delivered the UIKit dismissal synchronouslyCustomerCenterViewController.viewDidDisappear now calls viewModel.dismiss() before onDismiss?() releases the manager's retained delegate, and the debounce task captures the view model strongly so @StateObject teardown on the SwiftUI sheet path can't drop didDismiss/customerCenterClose. Both paths are covered by tests that would fail against the old code.
  • Wired the date formatters to the SDK's localePurchasePresentationBuilder takes a locale, PurchaseDetailRows reads it through the new CustomerCenterViewModel.locale, and both resolve to CustomerCenterEnvironmentProviding.locale — the same value LocalizationLogic.localizedBundle(nil) uses, so dates and copy no longer disagree.
  • Populated support diagnostics from entitlementsactiveEntitlementIds is captured from the latest CustomerInfo in apply(customerInfo:), matching the email's - Entitlements: label.
  • Paired hash with isEqual on Support, Appearance and ColorPair — every override now combines exactly the properties its isEqual compares, so equal configurations hash equally including across a Codable round-trip.
  • Switched the test suite to parallel executionparallelizable: true in project.yml (so it survives xcodegen), Cache.makeDefault() giving each defaulted cache its own on-disk directories under SUPERWALL_UNIT_TESTS, and timing-sensitive tests converted from fixed sleeps to a waitUntil poll.
  • Added four ### Fixes entries to the 4.17.0 changelog describing the above.

ℹ️ The parallel-execution isolation stops one line short of Storage's other on-disk store

Cache.makeDefault() namespaces the defaulted cache's three directories, but the sibling default on the same initializer — coreDataManager: CoreDataManager = CoreDataManager() (Storage.swift:85) — still resolves to one NSPersistentContainer(name: "SuperwallKit_Model") at the default store URL, shared across the 200 bare DependencyContainer() constructions in the test target. If shared on-disk state was what made concurrent runs contaminate each other, this is the same class of problem left untreated.

Worth noting alongside it: Swift Testing already parallelizes suites in-process by default — the repo has 19 test files opting out with .serialized, which is only meaningful if concurrency was already happening. So the Cache contamination this commit fixes was live before the scheme flip, and parallelizable: true mainly changes distribution for the 9 remaining XCTestCase classes rather than turning concurrency on.

Technical details
# Decide whether the shared Core Data store needs the same test-time namespacing as `Cache`

## Affected sites
- `Sources/SuperwallKit/Storage/Storage.swift:85``coreDataManager: CoreDataManager = CoreDataManager()` sits next to the now-namespaced `cache: Cache = .makeDefault()` and was not given equivalent treatment.
- `Sources/SuperwallKit/Storage/Core Data/CoreDataStack.swift:41-46``NSPersistentContainer(name: Self.modelName, managedObjectModel: Self.managedObject)` with no store description override, so every instance loads the same SQLite file.
- Test target — 200 call sites construct a bare `DependencyContainer()`, each building a real `CoreDataManager` against that one file. `StorageMock` avoids it via `CoreDataManagerFakeDataMock`; plain `DependencyContainer()` does not.

## Required outcome
- An explicit decision: either the Core Data store gets the same per-instance namespacing under `SUPERWALL_UNIT_TESTS`, or it is documented as safe to share concurrently so a future flake isn't re-investigated from scratch.

## Suggested approach (optional)
- Mirror `Cache.makeDefault()`: a `CoreDataManager.makeDefault()` that, under the test argument, points `persistentStoreDescriptions` at a UUID-named URL (or an in-memory store) before `loadPersistentStores`.

## Open questions for the human
- Was the observed cross-test contamination actually traced to `Cache`, or was `Cache` simply the first shared store found? If the former, this may genuinely be a non-issue and only needs a note.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread CHANGELOG.md
Comment on lines +14 to +17
- Fixes the Customer Center's dismissal callback and close event sometimes not firing.
- Formats Customer Center dates using the locale set in the SDK options instead of the device locale.
- Lists active entitlements instead of product identifiers in the Customer Center support email.
- Fixes equal Customer Center configurations hashing differently.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These four entries sit under ### Fixes in the same release whose ### Enhancements introduces the Customer Center, so they read as fixes to a feature nobody has shipped against. The second one is also inaccurate: environment.locale resolves to DeviceHelper.preferredLocaleIdentifier, which is Locale.preferredLanguages.first — the device language — not SuperwallOptions.localeIdentifier, so setting that option still changes neither the dates nor the strings.

Comment on lines +34 to +35
/// Locale for date formatting, matching the locale the localized strings resolve against
/// (`SuperwallOptions.localeIdentifier` when set) rather than the system locale.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

environment.locale is Locale(identifier: deviceHelper.preferredLocaleIdentifier), and preferredLocaleIdentifier (DeviceHelper.swift:22-27) returns Locale.preferredLanguages.first, falling back to the option-backed localeIdentifier only when preferredLanguages is empty. The fix itself is right — dates now resolve through exactly the value LocalizationLogic.localizedBundle(nil) uses — but the comment names a source that doesn't drive it, and the same sentence is repeated at PurchasePresentationBuilder.swift:24-25.

Suggested change
/// Locale for date formatting, matching the locale the localized strings resolve against
/// (`SuperwallOptions.localeIdentifier` when set) rather than the system locale.
/// Locale for date formatting, matching the locale the localized strings resolve against
/// (`DeviceHelper.preferredLocaleIdentifier`) rather than the system locale.

Comment thread Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift Outdated
DreamingInBinary and others added 2 commits August 25, 2026 14:06
The management screen collapsed non-subscription purchases to the first two. That is fine
while "See all purchases" is there to show the rest, but `showsPurchaseHistory` can switch
that row off — and then anything past the cap was simply unreachable.

Only collapse when the full list is still one tap away.

Also corrects a test comment that still named the old 0.3s dismissal debounce; the default
has been 0.6s since it was widened past a navigation transition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`CustomerCenterViewController` was modal-only by construction: a hardcoded `.pageSheet`
style, its own `NavigationView`, and a close button wired to `dismiss(animated:)` that does
nothing to a pushed controller. Its teardown check was modal-only too — bare
`presentingViewController == nil` is true for a pushed controller's entire lifetime, so
every cover event read as a dismissal, and because `dismiss()` latches, the real teardown
then went silent.

Adds `CustomerCenterPresentationStyle`. `.pushed` shows a back button instead of a close
button and hides the host's navigation bar while on screen, handing it back exactly as it
was found. The Customer Center keeps supplying its own bar in both styles because its
drill-downs are SwiftUI `NavigationLink`s, which do nothing without a SwiftUI navigation
ancestor — a surrounding `UINavigationController` is not one. Swipe-to-go-back is driven by
a private gesture delegate, kept off the view controller so the conformance doesn't land on
the SDK's public surface.

The controller is now a `UIHostingController` subclass rather than a plain controller
wrapping a child host, so SwiftUI's `.navigationTitle` reaches the host's bar instead of
stopping at an intermediate controller.

Teardown is now a walk up the parent chain for `isBeingDismissed`/`isMovingFromParent`,
which also catches a container the host tears down, plus a recorded `wasPresentedModally`
paired with `presentingViewController` to keep the modal path sound.

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

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

Important

.pushed closes the covered-is-not-dismissed hole at the view-controller level but leaves it open at the view-model level, so a host pushing its own screen over a pushed Customer Center still fires customerCenterDidDismiss() and customerCenterClose 0.6s later — and the latch then silences the real teardown. Details inline on CustomerCenterViewController.swift:177.

Reviewed changes — the two commits since the prior pullfrog review at 7ab4a98: 712eee1 (inline purchase cap) and a5b8fc1 (the .pushed presentation style).

  • Added a .pushed presentation style — a new public CustomerCenterPresentationStyle (@objc SWKCustomerCenterPresentationStyle) threaded through both Swift initializers and the ObjC one (now initWithConfiguration:presentationStyle:delegate:), so a host can push CustomerCenterViewController onto its own navigation stack instead of presenting it. CustomerCenterManager still always passes .modal.
  • Reshaped the controller into a UIHostingController<CustomerCenterView> subclass — the child hosting controller and its four constraints are gone; rootView is assigned a second time in init so the close/back closures can capture self.
  • Replaced the teardown check with isLeavingHierarchy — walks self and the parent chain for isBeingDismissed/isMovingFromParent, falling back to wasPresentedModally && presentingViewController == nil, which retires the prior review's presentingViewController == nil finding.
  • Made .pushed take over the host's chromeviewWillAppear records and hides the host navigation bar and installs a private InteractivePopGestureDelegate; viewWillDisappear hands the bar and delegate back.
  • Added showsBackButton/onBack to CustomerCenterNavigationOptions — a leading chevron toolbar item attached only when a back button is wanted, plus customer_center_back in all 41 locales.
  • Stopped hiding unreachable purchasesManagementScreenView.visibleOthers only applies the 2-item inline cap when showsPurchaseHistory is on, retiring the others.prefix(2) nitpick from the first review.
  • Added CustomerCenterViewControllerTests and ManagementScreenViewTests — real window/navigation-controller fixtures for the push, pop, cover and bar-restore paths, and a .serialized trait on CustomerCenterManagerTests.
  • Documented the pushed path — a DocC section, a CHANGELOG enhancement entry, and the stale "production default, 0.3s" test comment corrected to 0.6s.

⚠️ CustomerCenterManager has no idea a pushed Customer Center exists

isPresented and presentedController only ever track the manager's own modal presentation, and nothing in the .pushed path registers with it. With the Customer Center living in a host's settings stack — which is the integration the DocC section now recommends — Superwall.shared.presentCustomerCenter() will happily present a second, independent Customer Center over the top of it, and Superwall.shared.dismissCustomerCenter() is a silent no-op on the pushed one. That may well be the intended contract for a controller the host owns outright, but it isn't stated anywhere and the "already presented" guard reads as if it covers all cases.

Technical details
# Decide what `presentCustomerCenter` / `dismissCustomerCenter` mean while a pushed Customer Center is on screen

## Affected sites
- `Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift``isPresented` is `presentedController != nil`, and `presentedController` is only set by the manager's own `present(...)`. A `.pushed` controller built by the host never touches it.
- `Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift``dismiss(completion:)` returns early when `presentedController == nil`, so it completes successfully without dismissing anything.
- `Sources/SuperwallKit/Documentation.docc/CustomerCenter.md` — the new pushed section recommends the settings-screen integration but says nothing about how the two entry points interact.

## Required outcome
- An explicit, documented answer to "what happens if I call `presentCustomerCenter()` while my pushed one is on screen?" — either the manager learns about host-owned controllers, or the DocC article states that the two surfaces are independent and the host owns the lifecycle of the one it constructs.

## Open questions for the human
- Is two live Customer Centers (one pushed, one modal over it) an acceptable state, or should the manager refuse to present when any instance is on screen?

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift Outdated
Comment thread Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift Outdated
Comment thread Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift Outdated
…n style

Four findings from review of a5b8fc1.

The view-controller guard only closed half of "covered is not dismissed". SwiftUI's
`onDisappear` still ran on a cover — `UIHostingController` forwards the disappearance either
way — dropping the visible-surface count to zero and arming the 0.6s debounce, so the
dismissal simply arrived late and latched, silencing the genuine teardown. The controller
now vetoes that pending dismissal when it knows it is merely being covered. The existing
cover test asserted synchronously and so could never have caught this; the new one waits
past the debounce and then pops, asserting the real teardown still lands.

That test then exposed a second hole: a controller covered *and then* popped never gets a
second `viewDidDisappear`, so its teardown was never delivered at all. Removal from a
container is now handled in `didMove(toParent:)`, with the delivery latched because an
ordinary pop is both a disappearance and a removal.

Host navigation state was written more widely than it was restored. `isEnabled` was forced
true with nothing putting it back, permanently re-enabling swipe-to-go-back for a host that
had deliberately turned it off — and it turns out hiding the bar doesn't clear `isEnabled`
anyway, so the line only ever did harm. It's gone, and the delegate capture now has the same
idempotency guard as the bar's.

The pop gesture also stayed armed while the user was inside the Customer Center's own stack,
where two edge-pans were live for one swipe with no failure requirement between them; if the
host's had won, the user would have been thrown out of the Customer Center entirely rather
than going back one screen. It now stands down whenever a pushed surface is on screen.

Also documents that a host-constructed controller is independent of the SDK's own
presentation, since `presentCustomerCenter()` will happily stack a second one over it and
`dismissCustomerCenter()` is a no-op on it.

The support-email extension moves to its own file to keep the view model under the file
length limit.

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

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

Important

The cover-veto that closes the last review's dismissal hole depends on SwiftUI delivering onDisappear inside super.viewDidDisappear(_:) — an ordering Apple documents as view-type-dependent and ties to no UIKit callback. If it lands a turn later, cancelPendingDismissal() cancels a task that does not exist yet and the original bug survives unchanged. Details inline on CustomerCenterViewController.swift:192.

Reviewed changes — the single commit since the prior pullfrog review at a5b8fc1: 28691d5, which answers all three inline findings from that review.

  • Vetoed the debounced dismissal when a pushed controller is merely coveredviewDidDisappear's non-teardown branch now calls the new CustomerCenterViewModel.cancelPendingDismissal(), and a new didMove(toParent:) override delivers the teardown for a controller that was already covered when it got popped (UIKit gives it no second disappearance). Both go through a latched deliverDismissal(), since an ordinary pop is both a disappearance and a removal.
  • Stopped writing the host's interactivePopGestureRecognizer.isEnabled — the forced = true is gone, and the delegate capture gained the == nil idempotency guard its navigation-bar sibling already had. pushedRoundTripsTheInteractivePopGesture pins the round trip for a host that starts with the gesture both enabled and disabled.
  • Made the host's pop gesture stand down inside the Customer Center's own stack — the three drill-down surfaces now call surfaceDidAppear(isPushed:)/surfaceDidDisappear(isPushed:), feeding a separate pushedSurfaceCount that InteractivePopGestureDelegate consults, so the two nested edge-pans no longer race for the same swipe.
  • Documented that a host-constructed controller is independent of the SDK's entry points — a DocC > Important: note stating presentCustomerCenter will stack a second Customer Center over a pushed one and dismissCustomerCenter does nothing to it. This settles the design question the prior review's body section raised.
  • Extracted the support-email extension into CustomerCenterViewModel+Support.swift — a pure move, with dependencies and activeEntitlementIds relaxed from private to internal because private is file-scoped, plus the matching .pbxproj entries.
  • Added a dismissDebounceInterval hook to the test fixture and three tests covering the late-dismissal, gesture round-trip and nested-stack arbitration paths.

ℹ️ A covered Customer Center that goes away with its container now reports nothing

The veto trades a false positive for a false negative on one path. If a host pushes the Customer Center inside its own UINavigationController, pushes another screen over it, and then closes the whole flow by dismissing that navigation controller, the buried controller gets no second viewDidDisappear (it already disappeared, and that disappearance was vetoed) and no didMove(toParent: nil) (its containment never changes — the container is dismissed, not restructured). deliverDismissal() never runs, so customerCenterDidDismiss() and customerCenterClose never fire. Before this commit the debounce delivered that case — prematurely, but it delivered it.

Technical details
# A covered Customer Center torn down with its container delivers no dismissal

## Affected sites
- `Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift:192` — the cover branch cancels the only mechanism that would have fired for a controller that never gets another lifecycle callback.
- `Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift:198-207``didMove(toParent:)` catches removal from a container, but dismissing a presented container is not a containment change, so this does not fire for a controller buried inside it.
- `Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift:136-141` — on the manager's own modal path the same shape also leaves `retainedDelegate` retained, since `onDismiss` is what clears it. Only a `.fullScreen` cover reaches this; a `.pageSheet` over a `.pageSheet` produces no disappearance at all.

## Required outcome
- A Customer Center that is covered and then destroyed without a further appearance callback still delivers exactly one `customerCenterDidDismiss()` / `customerCenterClose`, and the manager's `retainedDelegate` is still released.

## Suggested approach (optional)
- Deallocation is the one signal that path does produce. A `deinit` on `CustomerCenterViewController` that runs `deliverDismissal()`'s cleanup when `hasDeliveredDismissal` is still `false` would cover it, if the `@MainActor` hop can be made safe there.

## Open questions for the human
- Is a host-owned Customer Center inside a modally-presented navigation flow a shape you intend to support, or is the DocC recommendation narrow enough that this is acceptable?

ℹ️ Nitpicks

  • Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift:60hasDeliveredDismissal never resets, and neither do didDismiss (CustomerCenterViewModel.swift:55) or hasTrackedOpen (:54). A host that retains one controller and pushes it again on each visit — which the new DocC section's settings-screen shape invites — gets no customerCenterOpen and no dismissal callbacks from the second visit onwards. The manager builds a fresh controller and view model per present, so only host-owned instances are affected; worth a sentence in the DocC section saying an instance is single-use.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

// case, which has no way to tell a cover from a teardown; here we know, so veto it. Left to
// fire it would deliver `customerCenterDidDismiss()` and track `customerCenterClose` while
// the screen sits on the back stack, and latch, silencing the genuine teardown later.
viewModel.cancelPendingDismissal()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This cancels a task that may not exist yet. It runs after super.viewDidDisappear(animated) on the assumption stated in the comment above — that super has already driven SwiftUI's onDisappear into surfaceDidDisappear() — but Apple documents onDisappear's exact moment as depending on the view type and ties it to no UIKit callback. If SwiftUI delivers it a runloop turn later, the veto is a no-op, the debounce arms unopposed, and the premature customerCenterDidDismiss() + customerCenterClose (and the latch that then silences the genuine teardown) is exactly the bug the last review flagged.

Technical details
# Make the cover-veto independent of when SwiftUI delivers `onDisappear`

## Affected sites
- `Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift:192``viewModel.cancelPendingDismissal()` after `super.viewDidDisappear(animated)`.
- `Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift:374-377``cancelPendingDismissal()` only cancels the current `dismissDebounceTask`; it has no effect on one armed afterwards.
- `Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift:111``.onDisappear { viewModel.surfaceDidDisappear() }`, the arming side of the race.
- `Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift:379-386``dismiss()` latches on `didDismiss`, so a single premature fire is permanent.

## Required outcome
- Covering a `.pushed` Customer Center must not deliver a dismissal regardless of whether SwiftUI's `onDisappear` runs before or after `viewDidDisappear` returns.

## Suggested approach (optional)
- Turn the veto into state the debounce consults at the moment it fires rather than a one-shot cancel: a `dismissalVetoed` flag set by `cancelPendingDismissal()`, cleared by `surfaceDidAppear(isPushed:)` (a genuine reappearance retracts it), and checked with a `guard !dismissalVetoed else { return }` immediately before `dismiss()` in the debounce task.

## Open questions for the human
- If this was verified on device for the cover case, which ordering did you observe? That would settle it without a code change, and is worth recording in the comment either way.

// Covered by the host's own screen.
navigation.pushViewController(UIViewController(), animated: false)
spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window == nil }
controller.viewDidDisappear(false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This manual call lands after spinRunLoop has already settled, so it forces the veto to run last — the one ordering production cannot guarantee. The test does still catch "no veto at all", but it passes either way on the race, and nothing in the suite establishes that SwiftUI's onDisappear fires under this hostless harness in the first place (hostPopGestureDefersToTheInnerStack:302 and every CustomerCenterViewModelTests case drive the view model directly instead). Arming the debounce explicitly after the veto pins the behaviour deterministically and fails against the current implementation.

Suggested change
controller.viewDidDisappear(false)
controller.viewDidDisappear(false)
// SwiftUI is not documented to deliver `onDisappear` inside `super.viewDidDisappear`, so
// simulate it landing after — the ordering the veto has to survive.
controller.viewModel.surfaceDidDisappear()

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.

2 participants