Repository navigation
iOS: EXC_BAD_ACCESS in Offerings.offering(identifier:) when RevenueCatUI.Paywall options include an offering #2018
Description
- I have updated Purchases SDK to the latest version (we're on 10.4.2; the code paths below are unchanged in 10.12.2 and purchases-ios 5.94.0)
- I have read the Contribution Guidelines
- I have searched the Community
- I have read docs.revenuecat.com
- I have searched for existing Github issues
Describe the bug
On iOS, <RevenueCatUI.Paywall> sometimes crashes with EXC_BAD_ACCESS (KERN_INVALID_ADDRESS at 0x0) inside RevenueCatUI while it resolves the offering passed in options.offering. The crashing thread is hashing the offering identifier for the Offerings.all lookup, and the NSString behind that identifier appears to have been freed already.
It's rare and only in production: 6 crashes from 6 users since Sept 26. Every one happened 0.3–2.3 s after the paywall screen opened right after sign-in, 40–400 ms after a fresh offerings response, while another thread was caching offerings (DeviceCache.cache(offerings:)), warming the paywall cache, or checking fonts.
-
Environment
- Platform: iOS
- SDK version: react-native-purchases and react-native-purchases-ui 10.4.2 (PurchasesHybridCommon/PurchasesHybridCommonUI 18.19.0, RevenueCat/RevenueCatUI 5.80.2)
- OS version: iOS 26.4.2 to 26.7 (iPhone12,8, iPhone14,3, iPhone14,5, iPhone15,3, iPhone17,1 and one more)
- Xcode/Android Studio version: Xcode 26.6
- React Native version: 0.87.1 (New Architecture, Hermes)
- SDK installation (CocoaPods + version or manual): CocoaPods 1.16.2
- How widespread is the issue. Percentage of devices affected: well under 1% of iOS users who open the paywall (6 users so far)
-
Debug logs that reproduce the issue
We don't have verbose logs from an affected device. These are the RevenueCat console lines our crash reporter captured before one of the crashes (t = paywall screen opened; offering ids redacted):
t+0.26s [RevenueCat] Could not find default package for paywall. Using first package instead. ... t+0.55s [RevenueCat] Mapping for '' could not be found. Falling back to system font. t+0.68s [RevenueCat] Could not find default package for paywall. Using first package instead. ... t+1.03s [RevenueCat] Could not find default package for paywall. Using first package instead. ... t+1.09s [RevenueCat] ⚠️ There's a problem with your configuration. No packages could be found for offering with identifier <id>. ... (one line per offering) t+1.19s (the same batch of offering warnings again) t+1.25s EXC_BAD_ACCESSWe read the repeated "default package" lines as the paywall being rebuilt as
optionschanged (see below). -
Steps to reproduce, with a description of expected vs. actual behavior
We haven't reproduced it locally; it looks timing-dependent. The production pattern:
-
Configure Purchases with
purchasesAreCompletedBy: MY_APP, sign in, and open the paywall screen while offerings are being refreshed. -
Fetch
Purchases.getOfferings()and render the current offering:<RevenueCatUI.Paywall options={{ offering, // offerings.current displayCloseButton: false, customVariables: { has_expired_subscription: CustomVariableValue.boolean(hasExpiredSubscription), has_introductory_offer: CustomVariableValue.boolean(hasIntroductoryOffer), }, }} purchaseLogic={purchaseLogic} />
-
Shortly after mount, re-render with a new
optionsobject because the custom-variable inputs finished loading. Each re-send makesPaywallViewWrapper -setOptions:callupdateWithOfferingIdentifier:presentedOfferingContext:again with the same offering.
Expected: the paywall loads (or reloads) without crashing.
Actual: occasionallyEXC_BAD_ACCESSinOfferings.offering(identifier:). -
-
Other information (e.g. stacktraces, related issues, suggestions how to fix, links for us to have context, eg. stackoverflow, etc.)
Crashing thread:
0 libobjc.A.dylib _lookUpImpOrForward 1 libobjc.A.dylib __objc_msgSend_uncached 2 libswiftCore.dylib _StringObject.sharedUTF8.getter 3 libswiftCore.dylib String.hash(into:) 4 protocol witness for Hashable._rawHashValue(seed:) in conformance String 5 specialized __RawDictionaryStorage.find<A>(_:) 6 specialized Dictionary.subscript.getter 7 RevenueCat Offerings.offering(identifier:) Offerings.swift:106 8 RevenueCatUI PurchaseHandler.resolvePaywallViewData(for:workflowsEndpointEnabled:) PurchaseHandler.swift:471Another thread at the same moment, caching the offerings response:
SynchronizedLargeItemCache.getFileURL(for:) SynchronizedLargeItemCache.swift:57 DeviceCache.cache(offerings:preferredLocales:appUserID:) DeviceCache.swift:226 closure #1 in OfferingsManager.handleOfferingsBackendResult(...) OfferingsManager.swift:328 closure #1 in OfferingsManager.createOfferings(from:loadedFromDiskCache:fetchPolicy:completion:) OfferingsManager.swift:305 CachingProductsManager.products(withIdentifiers:completion:) CachingProductsManager.swift:68 OfferingsManager.fetchProducts(withIdentifiers:fromResponse:completion:) OfferingsManager.swift:415 OfferingsManager.handleOfferingsBackendResult(...) OfferingsManager.swift:323 closure #1 in closure #2 in GetOfferingsOperation.getOfferings(completion:) GetOfferingsOperation.swift:75 HTTPClient.handle(urlResponse:request:urlRequest:data:error:requestStartTime:) HTTPClient.swift:557A third thread was in
DefaultPaywallFontsManager.fontIsAlreadyInstalled(fontName:fontFamily:)(PaywallFontManagerType.swift:123).What we think is happening (not confirmed):
- The string being hashed is the identifier RNPaywalls passes from
options.offering.identifier:-applyNonCustomVariableOptionsFromPendingOptions→-[RCPaywallViewController updateWithOfferingIdentifier:presentedOfferingContext:]. Hashing goes through_StringObject.sharedUTF8andobjc_msgSend, so it's a bridgedNSString, and the message send lands on a dead object. - Once the view is in the hierarchy,
setOptions:re-applies the offering on every options update, even when the offering hasn't changed. Each call setsconfiguration.content, whosedidSetreplaces the hosting controller and starts a new load. viewDidLoadalso startsprefetchExitOffer(), which callsresolveOffering(for: self.configuration.content)(the workflows endpoint is off by default in 5.80.2). Both that andPaywallView.loadPaywallData()reachresolvePaywallViewData(for:workflowsEndpointEnabled:), which awaitspurchases.offerings()and then callsofferings.offering(identifier:).- So a resolve for the previous content can still be suspended in
purchases.offerings()when the main thread replacesconfiguration.content. When the offerings response lands (second stack above), it resumes and hashes an identifier whose backing string has been released. The async stack ends atresolvePaywallViewData, so we can't tell which caller it was. - Toolchain: the first crash came in our first release built with Xcode 26.6 (crash reports show
clang-2100.1.1.101). Earlier releases were built with Xcode 26.1.1 (clang-1700.4.4.1), had the same RevenueCat versions and paywall code, and had no occurrences. That's only a correlation, but it means a compiler-side lifetime change is possible.
Latest versions: purchases-ios 5.94.0 still resolves
.offeringIdentifierthroughofferings.offering(identifier:)inPurchaseHandler, andPaywallViewWrapper.min react-native-purchases-ui 10.12.2 has the samesetOptions:/updateWithOfferingIdentifier:logic (it only adds the new event callbacks). We haven't run those versions in production.Possible fixes:
- In
PaywallViewWrapper, skipupdateWithOfferingIdentifier:when the offering identifier and presented offering context haven't changed. Unrelated option updates, such as custom variables, which iOS ignores after the first layout anyway, then wouldn't rebuild the paywall. - In RevenueCatUI, make sure an in-flight resolve owns its content and identifier, or is cancelled, when
configuration.contentis replaced.
Our workaround: we only ever show the current offering, so we stopped passing
options.offering(the native paywall then resolves.defaultOffering), and we keepoptionsfixed after mount sosetOptions:isn't re-sent. We'll report back if the crash stops once that ships. - The string being hashed is the identifier RNPaywalls passes from
Additional context
No Android equivalent in our crash reports. The crashing frames are in purchases-ios (RevenueCatUI), but we only hit them through the react-native-purchases-ui Paywall component, so we're filing here. Feel free to move it.
Activity
fix: own paywall offering identifiers instead of pointing at the call…
JZDesign commentedon Oct 19, 2026
For the last week I've attempted but I have not been able to reproduce this crash. Natively or using a react native application. Both in simulators and on real devices built with debug or release configurations. I have a theoretical fix, but I don't exactly like it and I'm reluctant to merge it without being able to reproduce this crash and validate that it actually resolves the issue