Skip to content

iOS: EXC_BAD_ACCESS in Offerings.offering(identifier:) when RevenueCatUI.Paywall options include an offering #2018

Open
RevenueCat/purchases-ios
#7976

Description

@JacquesLeupin

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.

  1. Environment

    1. Platform: iOS
    2. SDK version: react-native-purchases and react-native-purchases-ui 10.4.2 (PurchasesHybridCommon/PurchasesHybridCommonUI 18.19.0, RevenueCat/RevenueCatUI 5.80.2)
    3. OS version: iOS 26.4.2 to 26.7 (iPhone12,8, iPhone14,3, iPhone14,5, iPhone15,3, iPhone17,1 and one more)
    4. Xcode/Android Studio version: Xcode 26.6
    5. React Native version: 0.87.1 (New Architecture, Hermes)
    6. SDK installation (CocoaPods + version or manual): CocoaPods 1.16.2
    7. How widespread is the issue. Percentage of devices affected: well under 1% of iOS users who open the paywall (6 users so far)
  2. 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_ACCESS
    

    We read the repeated "default package" lines as the paywall being rebuilt as options changed (see below).

  3. Steps to reproduce, with a description of expected vs. actual behavior

    We haven't reproduced it locally; it looks timing-dependent. The production pattern:

    1. Configure Purchases with purchasesAreCompletedBy: MY_APP, sign in, and open the paywall screen while offerings are being refreshed.

    2. 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}
      />
    3. Shortly after mount, re-render with a new options object because the custom-variable inputs finished loading. Each re-send makes PaywallViewWrapper -setOptions: call updateWithOfferingIdentifier:presentedOfferingContext: again with the same offering.

    Expected: the paywall loads (or reloads) without crashing.
    Actual: occasionally EXC_BAD_ACCESS in Offerings.offering(identifier:).

  4. 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:471
    

    Another 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:557
    

    A 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.sharedUTF8 and objc_msgSend, so it's a bridged NSString, 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 sets configuration.content, whose didSet replaces the hosting controller and starts a new load.
    • viewDidLoad also starts prefetchExitOffer(), which calls resolveOffering(for: self.configuration.content) (the workflows endpoint is off by default in 5.80.2). Both that and PaywallView.loadPaywallData() reach resolvePaywallViewData(for:workflowsEndpointEnabled:), which awaits purchases.offerings() and then calls offerings.offering(identifier:).
    • So a resolve for the previous content can still be suspended in purchases.offerings() when the main thread replaces configuration.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 at resolvePaywallViewData, 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 .offeringIdentifier through offerings.offering(identifier:) in PurchaseHandler, and PaywallViewWrapper.m in react-native-purchases-ui 10.12.2 has the same setOptions: / updateWithOfferingIdentifier: logic (it only adds the new event callbacks). We haven't run those versions in production.

    Possible fixes:

    • In PaywallViewWrapper, skip updateWithOfferingIdentifier: 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.content is replaced.

    Our workaround: we only ever show the current offering, so we stopped passing options.offering (the native paywall then resolves .defaultOffering), and we keep options fixed after mount so setOptions: isn't re-sent. We'll report back if the crash stops once that ships.

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

  1. JZDesign commented on Oct 19, 2026

    @JZDesign

    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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions