Skip to content

Customer Center - #509

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

Customer Center#509
DreamingInBinary wants to merge 29 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 on lines +136 to +140
controller.onDismiss = { [weak self] in
self?.presentedController = nil
self?.retainedDelegate = nil
onDismiss?()
}

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 clears the only strong reference to the delegate the moment viewDidDisappear fires, but CustomerCenterViewModel.dismiss() — the sole emitter of callbacks.didDismiss and the customerCenterClose event — doesn't run until the 0.6s visibility debounce elapses. By then the adapter's weak swiftDelegate is nil, so customerCenterDidDismiss() is silently dropped for exactly the usage the docs and CustomerCenterExampleDelegate recommend ("safe to create a fresh instance each time you present").

Technical details
# `didDismiss` and `customerCenterClose` are dropped on the `presentCustomerCenter` path

## Affected sites
- `Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift:136-140``onDismiss` clears `retainedDelegate` synchronously.
- `Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift:83-88``viewDidDisappear` invokes that closure at dismissal completion.
- `Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterDelegateAdapter.swift:18-19,58-61` — the adapter holds the delegate `weak`, so once `retainedDelegate` is cleared the callback no-ops.
- `Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift:337-343,345-352``dismiss()` only runs after `dismissDebounceInterval` (default `0.6`, `:67`), and the debounce `Task` captures `[weak self]`.

## Two independent failures, same root cause
1. **Delegate already released.** Certain: `retainedDelegate` is the only strong reference the SDK promises, and it is gone ~0.6s before the callback fires.
2. **View model likely deallocated.** `CustomerCenterManager.presentedController` is `weak` (`:16`), so UIKit's release of the dismissed VC deallocates the VC, its `UIHostingController`, and the view model. The debounce `Task`'s `guard let self` then fails and `dismiss()` never runs at all — meaning `customerCenterClose` is never tracked either, on any presentation path where the view model doesn't outlive the debounce.

## Required outcome
- `customerCenterDidDismiss()` reaches a delegate passed to `Superwall.shared.presentCustomerCenter(delegate:)`.
- `customerCenterClose` is tracked exactly once per presentation, on both the UIKit/manager path and the SwiftUI sheet path.
- The existing embedded-navigation behaviour (`usesExistingNavigation`, push/pop within the Customer Center must not fire a dismissal) is preserved.

## Suggested approach (optional)
- The debounce exists only because embedded mode can't distinguish a nav transition from a teardown. A `CustomerCenterViewController` being dismissed knows definitively: have `viewDidDisappear` call `viewModel.dismiss()` directly (it's already idempotent via the `didDismiss` latch) *before* invoking `onDismiss`, and leave the debounce as the embedded-mode-only fallback.
- Whatever the mechanism, clear `retainedDelegate` after the view model has fired, not before.

## Open questions for the human
- Should the SwiftUI sheet path get the same treatment, or is `@StateObject` teardown there slow enough that the 0.6s debounce reliably wins? That one I couldn't determine from the code alone.

<TestableReference
skipped = "NO">
skipped = "NO"
parallelizable = "NO">

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 file is regenerated by xcodegen from project.yml, which scripts/build.sh, scripts/test.sh and the CI xcodegen-action all run — so this hand edit disappears on the next generation and the suite silently goes back to running in parallel. It needs to be parallelizable: false under targets.SuperwallKit.scheme.testTargets in project.yml instead. Separately, switching the whole suite to serial execution is a repo-wide policy change worth stating explicitly (which suites were racing?), since it costs CI time for every future test too.

Comment on lines +14 to +19
var dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter
}()

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 formatter (and the one in PurchaseDetailRows) uses Locale.current, while every string goes through CustomerCenterStrings.bundled()LocalizationLogic.localizedBundle(nil), which resolves against deviceHelper.preferredLocaleIdentifier. An app setting SuperwallOptions.localeIdentifier gets translated copy with system-locale dates. CustomerCenterEnvironmentProviding.locale (CustomerCenterDependencies.swift:46,157) already carries exactly the right value but is never read by anything — wiring it into both formatters closes the gap and removes the dead protocol requirement.

Comment on lines +99 to +100
hasher.combine(support)
hasher.combine(appearance)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Support, Appearance and ColorPair override isEqual but not hash, so these two lines mix identity hashes into a value-equality hash: two configurations that compare equal (e.g. .default twice, or an instance and its Codable round-trip) produce different hashes. Nothing in the SDK hashes this type today, but it's a public NSObject and so Hashable to consumers. Screen, Path, FeedbackSurvey and Option already pair both overrides — the three stragglers should match.

osVersion: env.osVersion,
deviceModel: env.deviceModel,
sdkVersion: env.sdkVersion,
activeEntitlementIds: active,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

active is purchases.filter(\.isActive).compactMap(\.productId) — product identifiers, not entitlement identifiers — but SupportEmailComposer renders the field as - Entitlements:. Either populate it from customerInfo.entitlements or rename the field and label to product IDs, so support isn't reading mislabelled diagnostics.

@linear-code

linear-code Bot commented Aug 21, 2026

Copy link
Copy Markdown

SW-5650

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