Skip to content

Dedupe Purchases.configure(with:) calls with equal Configuration#6811

Merged
ajpallares merged 19 commits into
mainfrom
pallares/hashable-configuration-via-storage
May 22, 2026
Merged

Dedupe Purchases.configure(with:) calls with equal Configuration#6811
ajpallares merged 19 commits into
mainfrom
pallares/hashable-configuration-via-storage

Conversation

@ajpallares

@ajpallares ajpallares commented May 17, 2026

Copy link
Copy Markdown
Member

Checklist

  • If applicable, unit tests
  • If applicable, create follow-up issues for purchases-android and hybrids

Motivation

Reported by RevenueCat/purchases-unity#930 and in order to match purchases-android's behavior: a second Purchases.configure(with:) with an equal Configuration should reuse the existing instance instead of allocating a new one and warning.

Description

  • Back Configuration, DangerousSettings, and Purchases.PlatformInfo with Hashable Storage structs so equality is synthesized and maintainable.
  • Store the Configuration on each Purchases and add a dedupingAgainst: parameter to setDefaultInstance: when the existing singleton's configuration matches, return it with an info log instead of replacing it. configure(with:) funnels through this path.
  • Drop the dead configure(withAPIKey:...) internal helper. New unit tests cover equality across all comparable fields, UserDefaults reference identity, and the dedup short-circuit.

UserDefaults is compared by reference identity, not contents. Equal Configurations share the same UserDefaults instance (or both nil); passing a different instance still triggers a rebuild.

Made with Cursor


Note

Medium Risk
Changes singleton initialization semantics for Purchases.configure(with:) by short-circuiting duplicate configurations, which could affect apps relying on reconfiguration side effects; mitigated by scoped equality rules and added unit tests.

Overview
Deduplicates repeated Purchases.configure(with:) calls by reusing the existing singleton when the new Configuration compares equal, avoiding the previous “instance already set” replacement behavior.

To support this, Configuration, DangerousSettings, and Purchases.PlatformInfo now implement isEqual/hash via Hashable backing storage (with DangerousSettings.internalSettings intentionally excluded and UserDefaults compared by reference). Purchases stores the currentConfiguration, setDefaultInstance gains a dedupingAgainst: parameter, and a new info log instance_already_exists_with_same_config is emitted when deduping occurs.

Adds unit tests covering equality/hash behavior and the configure dedup path, updates test initializers/signatures accordingly, and updates SwiftPM/Xcodeproj metadata (including new test files and additional dev/test dependencies in Package.resolved).

Reviewed by Cursor Bugbot for commit 6050d56. Bugbot is set up for automated code reviews on this repo. Configure here.

ajpallares and others added 3 commits May 17, 2026 19:29
Adds explicit property-read and constructor coverage for `DangerousSettings`
in both the Swift and Obj-C API testers so future refactors of the type
(e.g. moving stored properties into an internal value-type backing) are
guaranteed to remain source- and ABI-compatible for SDK consumers.

Swift (SwiftAPITester/DangerousSettingsAPI.swift):
- Exercise `DangerousSettings()` and `DangerousSettings(autoSyncPurchases:)`
  in addition to the existing `DangerousSettings(uiPreviewMode:)` case.
- Read all three publicly exposed properties (`autoSyncPurchases`,
  `customEntitlementComputation`, `uiPreviewMode`).

Obj-C (ObjcAPITester/RCDangerousSettingsAPI.{h,m}):
- New file mirroring the `RCConfigurationAPI` pattern (one
  `+ (void)checkAPI` per public type), wired into `main.m` and the
  `AllAPITests.xcodeproj` project file.
- Covers `-init`, `-initWithAutoSyncPurchases:`, and reads of the two
  Obj-C-exposed properties (`autoSyncPurchases`, `customEntitlementComputation`).
  `uiPreviewMode` is intentionally omitted since it is `@_spi(Internal)` and
  not bridged to Obj-C.

These additions are landed first so we can verify the existing public
surface compiles on `main`, before any refactor of `DangerousSettings`
internals is layered on top.

Co-authored-by: Cursor <cursoragent@cursor.com>
…rage struct

Moves the comparable stored properties of `Configuration`, `DangerousSettings`,
and `Purchases.PlatformInfo` into internal `Storage` value types that
automatically synthesize `Hashable`. The class-level `isEqual(_:)` and `hash`
overrides delegate to the storage, so adding a new field only requires touching
`Storage`—no parallel updates to equality boilerplate.

Notes:
- Public `let` properties on `DangerousSettings` are now `var { get }` computed
  from `storage`. This is callsite-compatible for both Swift and Obj-C
  consumers, but `.swiftinterface` baselines under `api/` will need to be
  refreshed via the existing `update_swiftinterface_baselines` Fastlane lane.
- `UserDefaults?` is intentionally part of `Configuration.Storage`'s equality
  via `NSObject` reference identity. This dedups the common case (both `nil`,
  both `.standard`, or the same cached suite) while still treating a different
  `UserDefaults` instance as a real configuration change.
- `internalSettings` on `DangerousSettings` is excluded from equality; it's an
  internal/debug mechanism containing non-`Hashable` closures and has no
  observable effect on the public configuration.
- `Signing.ResponseVerificationMode` is left untouched globally; a small
  case-only `ResponseVerificationModeKey` proxy inside `Configuration` is used
  in `Storage`, since the associated `PublicKey` is always the hardcoded value
  loaded by `Signing.loadPublicKey()`.

Foundation for an upcoming change that deduplicates `Purchases.configure(...)`
calls with identical configurations, mirroring purchases-android's behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
Mirrors `purchases-android`'s behavior of returning the existing
`Purchases` instance when `configure` is invoked again with a
`Configuration` equal to the one used previously.

Implementation:
- Store the `Configuration` used to create each `Purchases` instance on
  the instance itself (`currentConfiguration`).
- Add a `dedupingAgainst:` parameter to `setDefaultInstance`. When the
  current singleton's `currentConfiguration` equals the supplied one, the
  existing instance is returned and `purchases` is not invoked, avoiding
  an unnecessary `Purchases` allocation. A dedicated `ConfigureStrings`
  case logs that the existing instance is being reused.
- Route the public `Purchases.configure(with:)` directly through the new
  `setDefaultInstance(_:dedupingAgainst:)` and drop the legacy internal
  `configure(withAPIKey:...)` helper, which had no remaining callers.
  All other `configure(...)` overloads continue to funnel through
  `configure(with:)`, so every configure path now benefits from
  deduplication.

Tests:
- Add equality unit tests for `Configuration`, `DangerousSettings`, and
  `Purchases.PlatformInfo` (the three types now backed by `Hashable`
  storage structs), including `UserDefaults` reference-identity coverage
  for `Configuration`.
- Add dedup behavior tests covering repeated `Purchases.configure(with:)`
  with equal/equivalent `Configuration`s, with same/different
  `UserDefaults` references, plus a direct check of the equality
  short-circuit used by `setDefaultInstance(_:dedupingAgainst:)`.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ajpallares ajpallares added the pr:feat A new feature label May 17, 2026
ajpallares and others added 12 commits May 18, 2026 08:52
`Tests/UnitTests/Misc/DangerousSettingsTests.swift` and
`Tests/UnitTests/Misc/PlatformInfoTests.swift` were picked up by the
Tuist-generated `UnitTests` target via globbing, but the hand-maintained
`RevenueCat.xcodeproj` enumerates each file explicitly, so the Danger
project-sync check flagged them as missing.

Wire them into the `Misc` group, file references, and the `UnitTests`
sources build phase, mirroring how `ClockTests.swift` is registered.

Co-authored-by: Cursor <cursoragent@cursor.com>
The new info log is what developers will actually see when they
configure twice with the same `Configuration`. Talking about "returning
the existing instance" is misleading because the SDK does not surface a
return value to callers that hit the dedup path at runtime (the public
`configure(with:)` is `@discardableResult`).

Align with `purchases-android`'s `INSTANCE_ALREADY_EXISTS_WITH_SAME_CONFIG`:

- Rename `purchase_instance_already_set_with_same_config` to
  `instance_already_exists_with_same_config`.
- Use Android's exact wording: "Purchases instance already set with the
  same configuration. Ignoring duplicate call."

Co-authored-by: Cursor <cursoragent@cursor.com>
The doc paragraphs only narrated the obvious mechanic ("a `Hashable`
struct lets the synthesized conformances drive `isEqual`/`hash`"), which
the code itself already conveys. Keep the non-obvious bit: the comment
explaining why `internalSettings` is excluded from `DangerousSettings`'s
storage.

Co-authored-by: Cursor <cursoragent@cursor.com>
The doc comment for `setDefaultInstance(_:dedupingAgainst:)` already
describes the behavior on its own terms; pointing at another SDK as the
canonical reference adds noise.

Co-authored-by: Cursor <cursoragent@cursor.com>
There is no production "legacy" caller of
`setDefaultInstance(_:dedupingAgainst:)` — every public configure
overload routes through `configure(with: Configuration)`. The only
callers that pass `nil` are `BasePurchasesTests` and
`PurchasesSubscriberAttributesTests`, which build a `Purchases` directly
with mocks. Tighten the doc on both `setDefaultInstance` and
`currentConfiguration` to reflect that.

Co-authored-by: Cursor <cursoragent@cursor.com>
Drop the `= nil` default on `currentConfiguration` on both `Purchases`
initializers and pass `nil` explicitly from the two test helpers that
build `Purchases` directly with mocks. This way, future changes that
add a new code path producing a `Purchases` instance will be forced to
decide whether that instance participates in `configure(with:)`
deduplication instead of silently opting out.

Co-authored-by: Cursor <cursoragent@cursor.com>
Matches the cleanup already done on `DangerousSettings` and
`PlatformInfo`: the "Hashable struct synthesizes equality" paragraph
just narrates the obvious mechanic. Keep the `UserDefaults`
reference-identity note since that documents a non-obvious decision.

Co-authored-by: Cursor <cursoragent@cursor.com>
`Configuration.Storage` previously held a `ResponseVerificationModeKey`
proxy enum because `Signing.ResponseVerificationMode` is not `Hashable`
(its associated `PublicKey` is a CryptoKit type without `Hashable`
conformance) and the developer-facing `EntitlementVerificationMode`
could not be used as a destination in a `Signing.ResponseVerificationMode`
mapping switch (`.enforced` is `@available(*, unavailable)`).

The proxy is unnecessary if we keep the developer-facing setting on
both the `Builder` and `Configuration`, and only expand it into a
`Signing.ResponseVerificationMode` (which carries the hardcoded
`PublicKey`) on demand:

- `Builder.responseVerificationMode: Signing.ResponseVerificationMode`
  becomes `Builder.entitlementVerificationMode: EntitlementVerificationMode`,
  defaulting to `.informational` (preserving the previous runtime
  default of `Signing.ResponseVerificationMode.default`).
- `Configuration.Storage.entitlementVerificationMode` replaces
  `responseVerificationMode: ResponseVerificationModeKey`, so `Storage`
  keeps free synthesized `Hashable` conformance without a proxy.
- `Configuration.responseVerificationMode` becomes a `lazy var` that
  calls `Signing.verificationMode(with:)` (and thus
  `Signing.loadPublicKey()`) on first access. The only production
  reader is `Purchases.configure(with:)`, so the lazy resolution
  happens at the same point the eager resolution used to.

Drops the proxy enum and its rationale doc comment.

Co-authored-by: Cursor <cursoragent@cursor.com>
The doc explains an internal mechanic (when the lazy var resolves)
that the reader can see from the `lazy` keyword itself.

Co-authored-by: Cursor <cursoragent@cursor.com>
The instance-identity check and the log-message check exercised the
same `configure → configure` setup. Collapse them into one test so we
only build the dedup scenario once and the assertion list reads in one
pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ajpallares ajpallares marked this pull request as ready for review May 18, 2026 09:16
@ajpallares ajpallares requested a review from a team as a code owner May 18, 2026 09:16
@ajpallares ajpallares requested a review from a team May 20, 2026 11:30

@tonidero tonidero left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice! Looks great to me!

Base automatically changed from pallares/dangerous-settings-api-test-coverage to main May 20, 2026 18:38
ajpallares and others added 3 commits May 22, 2026 17:46
…ration tests

`BaseBackendIntegrationTests.configurePurchases()` calls the internal
long-form `Purchases.configure(withAPIKey:..., responseVerificationMode:
Signing.ResponseVerificationMode, ...)` helper to opt into strict signing
verification without going through `Configuration` (whose public
`EntitlementVerificationMode.enforced` case is marked
`@available(*, unavailable)`).

The previous dedup commit inadvertently removed this helper while
consolidating `configure(with:)` paths, breaking compilation of all five
`backend-integration-tests-*` CI jobs.

Restore the helper and forward `currentConfiguration: nil` so this path
opts out of dedup, mirroring the behavior of the other test-only call
sites that construct `Purchases` directly with mocks.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ajpallares

Copy link
Copy Markdown
Member Author

@RCGitBot please test

@ajpallares ajpallares merged commit 373cba1 into main May 22, 2026
39 of 42 checks passed
@ajpallares ajpallares deleted the pallares/hashable-configuration-via-storage branch May 22, 2026 19:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants