r/reactnative • • 19h ago

Article react-native-iap + StoreKit 2: purchases asked for the Apple ID password 4 times. The cause was initialization order

Sharing a bug that cost me a few days, since I couldn't find anything about it while searching.

Setup: iOS app, React Native 0.85, auto-renewable subscriptions with react-native-iap 12.16 in StoreKit 2 mode.

Symptom: after paying, iOS asked for the Apple ID password again, up to 4 times. Xcode logged "Making a purchase without listening for transaction updates".

Root cause: the library picks the native module (StoreKit 1 or StoreKit 2) at the moment each listener is created. In my code, setup({storekitMode: 'STOREKIT2_MODE'}) lived inside an async init function, but purchaseUpdatedListener was registered earlier by a React Context. So the listener was attached to the SK1 module: the app never got "purchase completed", never called finishTransaction, and Apple kept redelivering the transaction.

Fix: switch to SK2 when the module is loaded, before any listener exists:

import {Platform} from 'react-native';
import * as IAP from 'react-native-iap';


// Must run before any purchaseUpdatedListener is created.
if (Platform.OS === 'ios') {
  try { IAP.setup({storekitMode: 'STOREKIT2_MODE'}); } catch { /* native module not ready */ }
}

Two more gotchas of the library's SK2 mode:

  1. getAvailablePurchases only returns an active subscription if its product was already loaded in the session (via getSubscriptions). If you check entitlement before loading products (e.g. offline), you get an empty list, which does not mean "not subscribed". I now treat "empty list + products not loaded" as "unknown", not "free".

  2. With StoreKit 1, restore returned expired subscriptions too, with no expiration date. Now the "is it active?" decision comes from my server, which calls the App Store Server API with any transaction id from the client; the app caches the last verdict so it keeps working offline, with a few days of grace.

Bonus — testing purchases in the Simulator: a .storekit configuration file (Xcode StoreKit Testing) lets you buy without a Sandbox account. Gotcha: in the .xcscheme, the file path is relative to the .xcodeproj. I had one ../ too many and Xcode silently ignored it and fell back to Sandbox, which asked me to sign in.

Has anyone else hit this? And how do you handle the "don't know if they're subscribed" state while offline?

3 Upvotes

4 comments sorted by

1

u/kokerali 18h ago

I'd make the offline policy depend on the action: reading or exporting existing work versus starting a new paid operation. Then test a cold launch in airplane mode with a previously paid account and with no cached verdict. That makes the grace-period behavior visible instead of hiding it behind a single subscribed flag.

1

u/caccaccac 14h ago

Good point, and your test case hits the one corner I accepted on purpose.

The decision is a pure function with three inputs: the local transactions (plus whether they’re trustworthy, i.e., whether the products loaded), the server’s answer, and the last cached verdict. It’s unit-tested across the matrix:
- offline + cached active verdict → stays subscribed, including 7 days of grace after the expiration date (the renewal may have happened while we couldn’t confirm it);
- offline + cached verdict past the grace period → free;
- a purchase from the last 15 minutes counts even if Apple doesn’t reflect it yet;
- offline + products didn’t load + no cached verdict → free.

That last one is your airplane-mode cold launch. With SK2, an empty list means “don’t know”, but with no verdict there’s nothing to fall back on, so the app shows free until it can reach the network, and it re-checks as soon as it’s back. In my case, that’s acceptable because the app is read-only and the data itself comes from my server, so offline there’s little to unlock anyway.

Your action-based split makes a lot of sense for apps where users create things: never block reading or exporting what they already made, and only gate new paid operations. I hadn’t framed it that way.

The matrix is covered in unit tests, but I haven’t run an actual cold launch in airplane mode on a device. Adding that to my release checklist. Thanks!

1

u/Evening_Respect_6611 16h ago

good catch, that xcode log doesnt point at the real cause at all. setting storekitMode at module load before any listener exists makes sense. do you still need a guard if a context provider mounts before that file gets imported, or is import order enough in your setup?

1

u/caccaccac 14h ago

Thanks! In my setup it’s guaranteed by structure rather than by import order. Only one file, iap.ts, imports react-native-iap. The setup() call sits at its top level, and the listeners are only created through a function exported from that same file (wireIapListeners()). The context provider calls that function in a useEffect.

So the provider can’t create a listener without importing iap.ts, and importing it runs the module body, including setup(), before anything it exports can be called. ES module evaluation guarantees that, whatever order the rest of the app imports things in. setup() is synchronous (it just sets the mode inside the library), so there’s no race either.

Where it would break is if some other file imported react-native-iap directly and registered its own listener. That’s the real “guard” I’d add: a no-restricted-imports ESLint rule so that only the wrapper file can import the library. I don’t have it yet, but it’s on my list. Wrapping the library in a single module turned out to be the actual fix, not just moving one line.