Dedupe Purchases.configure(with:) calls with equal Configuration#6811
Merged
Conversation
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>
2 tasks
`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>
…/github.com/RevenueCat/purchases-ios into pallares/hashable-configuration-via-storage
tonidero
approved these changes
May 20, 2026
tonidero
left a comment
Contributor
There was a problem hiding this comment.
Nice! Looks great to me!
Base automatically changed from
pallares/dangerous-settings-api-test-coverage
to
main
May 20, 2026 18:38
…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>
Member
Author
|
@RCGitBot please test |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Checklist
purchases-androidand hybridsMotivation
Reported by RevenueCat/purchases-unity#930 and in order to match
purchases-android's behavior: a secondPurchases.configure(with:)with an equalConfigurationshould reuse the existing instance instead of allocating a new one and warning.Description
Configuration,DangerousSettings, andPurchases.PlatformInfowithHashableStoragestructs so equality is synthesized and maintainable.Configurationon eachPurchasesand add adedupingAgainst:parameter tosetDefaultInstance: when the existing singleton's configuration matches, return it with an info log instead of replacing it.configure(with:)funnels through this path.configure(withAPIKey:...)internal helper. New unit tests cover equality across all comparable fields,UserDefaultsreference identity, and the dedup short-circuit.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 newConfigurationcompares equal, avoiding the previous “instance already set” replacement behavior.To support this,
Configuration,DangerousSettings, andPurchases.PlatformInfonow implementisEqual/hashviaHashablebacking storage (withDangerousSettings.internalSettingsintentionally excluded andUserDefaultscompared by reference).Purchasesstores thecurrentConfiguration,setDefaultInstancegains adedupingAgainst:parameter, and a new info loginstance_already_exists_with_same_configis 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.