Skip to content

fix(apple): dedupe purchase update emissions#153

Merged
hyochan merged 17 commits into
mainfrom
fix/apple-purchase-update-dedupe
May 13, 2026
Merged

fix(apple): dedupe purchase update emissions#153
hyochan merged 17 commits into
mainfrom
fix/apple-purchase-update-dedupe

Conversation

@hyochan
Copy link
Copy Markdown
Member

@hyochan hyochan commented May 12, 2026

Summary

  • Track emitted iOS purchase transaction IDs for the current connection session.
  • Gate both direct requestPurchase emissions and Transaction.updates emissions through the same guard.
  • Add unit coverage for duplicate transaction IDs and reset/reconnect behavior.

Refs #152

Test plan

  • cd packages/apple && swift test
  • cd packages/apple && swift build

Summary by CodeRabbit

  • New Features

    • Optional listener flag to opt into receiving iOS StoreKit replay (duplicate) purchase updates; listener APIs now accept an options argument across platforms and SDKs.
  • Behavior Changes / Bug Fixes

    • Default suppression of duplicate purchase-update delivery within a connection session.
    • Bounded emission history (capped) to limit memory and evict old entries.
    • Emission gating to avoid double-processing when transactions are finished/stored.
  • Tests

    • Added tests for emission deduplication, snapshot behavior, and replay after session reset.
  • Documentation

    • Docs, examples, and type references for PurchaseUpdatedListenerOptions added/updated.

Review Change Stack

Track emitted iOS purchase transaction IDs for the current connection session so StoreKit replays cannot fire the same purchase update twice through requestPurchase and Transaction.updates paths.

Refs #152
@hyochan hyochan added react-native-iap react-native-iap library 🐛 bug Something isn't working 📱 iOS Related to iOS labels May 12, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 12, 2026

Warning

Rate limit exceeded

@hyochan has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 46 minutes and 34 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dd18526b-cafb-4d16-9972-6aff474354da

📥 Commits

Reviewing files that changed from the base of the PR and between 0cbb598 and 27586b8.

📒 Files selected for processing (2)
  • libraries/expo-iap/src/__tests__/index.test.ts
  • libraries/expo-iap/src/index.ts

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Per-session dedupe: IapState records emitted purchase-update IDs with a capped FIFO history and exposes recordPurchaseUpdateEmission(id:pendingTransaction:). requestPurchase and Transaction.updates consult it to suppress or mark duplicate emissions. Listeners can opt into duplicate replays via PurchaseUpdatedListenerOptions; changes propagate to SDKs, bindings, tests, and docs.

Changes

Purchase Update Replay Controls

Layer / File(s) Summary
Core models & state
packages/apple/Sources/Helpers/IapState.swift, packages/apple/Sources/Models/Types.swift
Add PurchaseUpdateEmissionHistory, store on IapState, implement recordPurchaseUpdateEmission(id:pendingTransaction:) -> Bool, clear on reset(), and add PurchaseUpdatedListenerOptions plus listener registration storing dedupeTransactionIOS.
OpenIap emission integration
packages/apple/Sources/OpenIapModule.swift
requestPurchase and Transaction.updates call state.recordPurchaseUpdateEmission(...) and conditionally finish/store/emit; emitPurchaseUpdate and logging accept duplicate metadata and snapshot listeners by duplicate preference.
ObjC/Protocol/Store wiring & tests
packages/apple/Sources/OpenIapModule+ObjC.swift, packages/apple/Sources/OpenIapProtocol.swift, packages/apple/Sources/OpenIapStore.swift, packages/apple/Tests/*
Add Objective-C overloads and update protocol/store signatures to accept optional PurchaseUpdatedListenerOptions (default nil); update fakes and tests to the new listener signature and verify dedupe/reset/snapshot behavior.
Expo iOS helper & module
libraries/expo-iap/ios/*, libraries/expo-iap/src/*
Manage purchaseUpdated subscription separately, add setPurchaseUpdatedListenerOptions native API and JS wiring, module-level duplicate listener count and native configuration, and update mocks/tests.
React Native IAP (JS + native)
libraries/react-native-iap/*
Extend JS purchaseUpdatedListener to accept options, route duplicate-enabled listeners to separate native attachments, add shared emitter helper, update native signatures and tests to token-based listener registration.
Flutter plugin & library
libraries/flutter_inapp_purchase/*
Add method-channel setPurchaseUpdatedListenerOptions, Dart purchaseUpdatedListenerWithOptions, centralized handling, and update subscription handler signatures and tests.
KMP / Java / build
libraries/kmp-iap/*, packages/google/openiap/*, libraries/godot-iap/ios-gdextension/Package.swift
Add PurchaseUpdatedListenerOptions types, update subscription/resolver signatures and platform implementations, and support local OpenIAP dependency in build scripts.
Godot plugin & Swift gdextension
libraries/godot-iap/*
Add GDScript API and types to set and apply purchase-updated listener options on iOS and Swift gdextension re-registration when options change.
MAUI
libraries/maui-iap/*
Add binding overload, PurchaseUpdatedWithOptions observable, types, DelegateObservable/DisposableAction helpers, and fallback behavior.
Docs, GraphQL, release
packages/docs/*, packages/gql/*, openiap-versions.json
Add docs and type pages for PurchaseUpdatedListenerOptions, update search data, GraphQL schema/types, release notes, and bump spec to 2.0.2.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • hyodotdev/openiap#34: Modifies iOS purchase flow in packages/apple/Sources/OpenIapModule.swift; may overlap with requestPurchase/emission logic.
  • hyodotdev/openiap#55: Also modifies requestPurchase path and related purchase handling.

Poem

🐰 I watched the transactions hop and play,
I record the first, let repeats stay away.
If you ask kindly, I’ll let echoes through,
Otherwise one ping—then silence anew.
—🐇

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/apple-purchase-update-dedupe

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a deduplication mechanism for purchase updates in the Apple package to prevent re-emitting the same transaction multiple times during a session. It adds state tracking for emitted transaction IDs with a limit of 512 entries and updates the OpenIapModule to skip duplicate emissions. Feedback indicates a high-severity issue in IapState.swift where storePendingAndRecordPurchaseUpdateEmission unconditionally adds transactions to the pending map before checking for duplicates, which could lead to state leaks and stale data.

Comment thread packages/apple/Sources/Helpers/IapState.swift Outdated
@hyochan
Copy link
Copy Markdown
Member Author

hyochan commented May 13, 2026

@coderabbitai review

@hyochan
Copy link
Copy Markdown
Member Author

hyochan commented May 13, 2026

/gemini review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements a deduplication mechanism for In-App Purchase (IAP) transaction emissions on Apple platforms by introducing a PurchaseUpdateEmissionHistory tracker within IapState. This ensures that transaction IDs are only emitted once per session, addressing potential replays from StoreKit. The OpenIapModule now utilizes this tracking in both the purchase flow and the transaction listener, and new unit tests verify the deduplication and reset functionality. I have no feedback to provide.

@hyochan
Copy link
Copy Markdown
Member Author

hyochan commented May 13, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements a deduplication mechanism for In-App Purchase transaction emissions by introducing a PurchaseUpdateEmissionHistory tracker within the IapState actor. This ensures that transaction updates are emitted only once per session, even if replayed by StoreKit. Feedback identifies a potential race condition in OpenIapModule where interleaved actor calls could lead to inconsistent state, suggesting the use of an atomic method. Additionally, it is recommended to replace the magic number used for the history limit with a named constant.

Comment thread packages/apple/Sources/Helpers/IapState.swift Outdated
Comment thread packages/apple/Sources/OpenIapModule.swift Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
libraries/expo-iap/src/index.ts (1)

190-223: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Protect iOS duplicate-listener refcount on add/remove failure paths.

At Line 195 the counter is incremented before emitter.addListener(...); if that throws, the refcount and native duplicate-option state remain incorrect. Similarly, if remove() throws, decrement/configure can be skipped.

🔧 Suggested fix
 export const purchaseUpdatedListener = (
   listener: (event: Purchase) => void,
   options?: PurchaseUpdatedListenerOptions | null,
 ) => {
@@
-  if (includeDuplicateTransactionUpdatesIOS) {
-    duplicatePurchaseUpdatedListenerCountIOS += 1;
-    configurePurchaseUpdatedListenerOptionsIOS(true);
-  }
+  if (includeDuplicateTransactionUpdatesIOS) {
+    duplicatePurchaseUpdatedListenerCountIOS += 1;
+    configurePurchaseUpdatedListenerOptionsIOS(true);
+  }

   const wrappedListener = (event: Purchase) => {
     const normalized = normalizePurchasePlatform(event);
     listener(normalized);
   };
-  const emitterSubscription = emitter.addListener(
-    OpenIapEvent.PurchaseUpdated,
-    wrappedListener,
-  );
+  let emitterSubscription: {remove: () => void};
+  try {
+    emitterSubscription = emitter.addListener(
+      OpenIapEvent.PurchaseUpdated,
+      wrappedListener,
+    );
+  } catch (error) {
+    if (includeDuplicateTransactionUpdatesIOS) {
+      duplicatePurchaseUpdatedListenerCountIOS = Math.max(
+        0,
+        duplicatePurchaseUpdatedListenerCountIOS - 1,
+      );
+      configurePurchaseUpdatedListenerOptionsIOS(
+        duplicatePurchaseUpdatedListenerCountIOS > 0,
+      );
+    }
+    throw error;
+  }
@@
   return {
     remove: () => {
-      emitterSubscription?.remove?.();
-      duplicatePurchaseUpdatedListenerCountIOS = Math.max(
-        0,
-        duplicatePurchaseUpdatedListenerCountIOS - 1,
-      );
-      configurePurchaseUpdatedListenerOptionsIOS(
-        duplicatePurchaseUpdatedListenerCountIOS > 0,
-      );
+      try {
+        emitterSubscription?.remove?.();
+      } finally {
+        duplicatePurchaseUpdatedListenerCountIOS = Math.max(
+          0,
+          duplicatePurchaseUpdatedListenerCountIOS - 1,
+        );
+        configurePurchaseUpdatedListenerOptionsIOS(
+          duplicatePurchaseUpdatedListenerCountIOS > 0,
+        );
+      }
     },
   };
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/expo-iap/src/index.ts` around lines 190 - 223, The refcount and
native duplicate-option state are updated even if emitter.addListener or the
returned remove() call throws; to fix, only increment
duplicatePurchaseUpdatedListenerCountIOS and call
configurePurchaseUpdatedListenerOptionsIOS(true) after emitter.addListener
successfully returns (i.e., move the increment/configure below where
emitterSubscription is non-null), and make the remove implementation robust by
wrapping emitterSubscription.remove() in try/finally so
duplicatePurchaseUpdatedListenerCountIOS is decremented and
configurePurchaseUpdatedListenerOptionsIOS(...) is always called regardless of
remove() errors; update code around includeDuplicateTransactionUpdatesIOS,
emitter.addListener, wrappedListener, emitterSubscription and the returned
remove() to follow this pattern.
🧹 Nitpick comments (3)
packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx (1)

8-8: ⚡ Quick win

Add JSDoc for the exported docs page component.

This exported component should include a short JSDoc block per repository conventions.

Proposed change
+/**
+ * Renders documentation for PurchaseUpdatedListenerOptions and iOS duplicate replay behavior.
+ */
 function PurchaseUpdatedListenerOptions() {

As per coding guidelines: "Add JSDoc comments for public functions and exported APIs".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx` at
line 8, Add a short JSDoc block above the exported
PurchaseUpdatedListenerOptions component describing its purpose as a docs page
component, noting it is exported and what it returns; include a one-line
description and a `@returns` tag (e.g., React.ReactElement) following repository
JSDoc conventions so the exported function PurchaseUpdatedListenerOptions has
the required public API documentation.
packages/apple/Sources/OpenIapModule.swift (2)

1821-1842: 💤 Low value

Minor: debug log misleads when an isDuplicate event has zero opt-in listeners.

When isDuplicate == true and listeners.count == 0, logDuplicatePurchaseUpdate already reports the emission as "Suppressed", but the very next line still logs ✅ Emitting purchase update: ... Listeners=0 and an empty MainActor.run block executes. Consider short-circuiting when there is nothing to deliver, both for log clarity and to avoid an unnecessary main-actor hop.

♻️ Suggested short-circuit
             if isDuplicate {
                 self.logDuplicatePurchaseUpdate(
                     source: duplicateSource ?? "unknown",
                     transactionId: duplicateTransactionId ?? purchase.id,
                     productId: purchase.productId,
                     listenerCount: listeners.count
                 )
+                if listeners.isEmpty { return }
             }
             OpenIapLog.debug("✅ Emitting purchase update: Product=\(purchase.productId), Listeners=\(listeners.count)")
             await MainActor.run {
                 listeners.forEach { $0(purchase) }
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/apple/Sources/OpenIapModule.swift` around lines 1821 - 1842, The
emitPurchaseUpdate function logs and dispatches even when a duplicate purchase
has zero opt-in listeners; after calling state.snapshotPurchaseUpdated(...)
check if listeners.isEmpty and isDuplicate == true and return early to avoid the
misleading "Emitting purchase update" debug log and the unnecessary await
MainActor.run block. Specifically, in emitPurchaseUpdate, after obtaining
listeners from state.snapshotPurchaseUpdated, if isDuplicate is true and
listeners.count == 0, call logDuplicatePurchaseUpdate as you already do (or
ensure it's already called) and then short-circuit (return) before the
OpenIapLog.debug and MainActor.run invocation so nothing is dispatched to the
main actor.

1802-1819: 💤 Low value

Consider lowering the duplicate-dedup log level from warn to debug/info.

Both branches of logDuplicatePurchaseUpdate describe expected behavior — default listeners receiving one event per id, and opt-in listeners receiving StoreKit replays. OpenIapLog.warn will fire for every Transaction.updates replay (which StoreKit can emit frequently for unfinished subscription transactions and on each reconnect), producing noisy logs at a severity that typically implies "something went wrong." debug for the "Suppressed" path and info for the "Delivered" path would convey intent without polluting production logs.

♻️ Suggested log level tweak
-        let action = listenerCount > 0
-            ? "Delivered duplicate purchase-updated event to \(listenerCount) duplicate-enabled listener(s)."
-            : "Suppressed duplicate purchase-updated listener emission."
-        OpenIapLog.warn("""
-            [PurchaseUpdateDedup] \(action)
-            - Source: \(source)
-            - Product: \(productId)
-            - Transaction ID: \(transactionId)
-            - Reason: this transaction id was already emitted during the current connection session.
-            - Scope: default listeners receive one event per transaction id; listeners registered with includeDuplicateTransactionUpdatesIOS receive StoreKit replays.
-            """)
+        let action = listenerCount > 0
+            ? "Delivered duplicate purchase-updated event to \(listenerCount) duplicate-enabled listener(s)."
+            : "Suppressed duplicate purchase-updated listener emission."
+        let message = """
+            [PurchaseUpdateDedup] \(action)
+            - Source: \(source)
+            - Product: \(productId)
+            - Transaction ID: \(transactionId)
+            - Reason: this transaction id was already emitted during the current connection session.
+            - Scope: default listeners receive one event per transaction id; listeners registered with includeDuplicateTransactionUpdatesIOS receive StoreKit replays.
+            """
+        if listenerCount > 0 {
+            OpenIapLog.info(message)
+        } else {
+            OpenIapLog.debug(message)
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/apple/Sources/OpenIapModule.swift` around lines 1802 - 1819, The
current logDuplicatePurchaseUpdate function uses OpenIapLog.warn for both
branches which is noisy; change the call to use lower log levels: call
OpenIapLog.info for the branch where listenerCount > 0 (the "Delivered duplicate
purchase-updated event...") and OpenIapLog.debug for the branch where
listenerCount == 0 (the "Suppressed duplicate..." case). Update the invocation
in logDuplicatePurchaseUpdate to choose the appropriate logging method based on
listenerCount while keeping the exact message text and interpolations (source,
productId, transactionId) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart`:
- Around line 112-135: The code currently treats listener options as per-call
but they mutate shared native state and are applied with unawaited, causing
races and dropped events; change purchaseUpdatedListenerWithOptions to await
_setPurchaseUpdatedListenerOptions(options) before returning
purchaseUpdatedListener (remove unawaited) and serialize concurrent calls to
_setPurchaseListener/_setPurchaseUpdatedListenerOptions using a shared
mutex/lock or a pending future to ensure only one native reconfiguration runs at
a time; keep the single native method name setPurchaseUpdatedListenerOptions and
ensure options are documented/stored as global state rather than per-stream so
callers can't overwrite each other unexpectedly.

In `@libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift`:
- Around line 118-123: The method setPurchaseUpdatedListenerOptions currently
swallows JSON decode errors and always returns true; update it so when
JSONSerialization fails or the decoded value isn't a [String: Any], the function
returns false (indicating malformed options) rather than proceeding, and only
return true after successfully parsing and applying
includeDuplicatePurchaseUpdatesIOS; locate and modify
setPurchaseUpdatedListenerOptions and its use of JSONSerialization/json decoding
to validate the parsed object before setting includeDuplicatePurchaseUpdatesIOS
and returning success.

In
`@libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt`:
- Around line 40-43: The duplicate-update listener registered via
openIapModule.addPurchaseUpdatedListener currently prints the full purchase
dictionary (the println in the lambda) which may expose sensitive tokens; remove
that raw payload logging or replace it with minimal non-sensitive metadata
(e.g., event type or masked id) before forwarding to convertAnyToPurchase and
trySend. Locate the lambda passed to addPurchaseUpdatedListener in
InAppPurchaseIOS.kt and delete or redact the println("[KMP-IAP iOS] Purchase
updated received with options: $dictionary") so only safe, non-payload
information is logged (or no log at all) while leaving convertAnyToPurchase(...)
and trySend(it) intact.

In `@libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs`:
- Around line 129-164: CreatePurchaseUpdatedObservable currently registers
native listeners via _module.AddPurchaseUpdatedListener but never tracks those
tokens for global cleanup, so Dispose() can leave native callbacks active; fix
by maintaining a concurrent/synchronized collection named
_dynamicPurchaseUpdatedTokens (or use the existing field) and: when you obtain
token inside CreatePurchaseUpdatedObservable (inside the lock on _listenerLock
and after checking _disposed), add the token to _dynamicPurchaseUpdatedTokens;
update the DisposableAction returned by CreatePurchaseUpdatedObservable to also
remove that token from _dynamicPurchaseUpdatedTokens before calling
RemoveListener(token, nameof(PurchaseUpdatedWithOptions)) and
GC.KeepAlive(callback); finally, update Dispose() to snapshot and iterate
_dynamicPurchaseUpdatedTokens (under _listenerLock) and call RemoveListener for
each token, clearing the collection so all dynamic listeners added via
CreatePurchaseUpdatedObservable are removed on Dispose.

In `@libraries/react-native-iap/ios/HybridRnIap.swift`:
- Around line 943-965: removePurchaseUpdatedListener currently clears both
listener buckets; change it to mirror addPurchaseUpdatedListener's behavior:
determine includeDuplicateTransactionUpdatesIOS from the passed
NitroPurchaseUpdatedListenerOptions, then inside listenerLock.withLock remove
the most recently added listener from the corresponding array (use
purchaseUpdatedDuplicateListeners.removeLast() or
purchaseUpdatedListeners.removeLast()) rather than calling removeAll(); ensure
you guard against empty arrays to avoid a crash (no-op if the target array is
empty). Update both occurrences referenced (the primary
removePurchaseUpdatedListener implementation and the other block at the 971-975
region) to use this LIFO removal logic.

---

Outside diff comments:
In `@libraries/expo-iap/src/index.ts`:
- Around line 190-223: The refcount and native duplicate-option state are
updated even if emitter.addListener or the returned remove() call throws; to
fix, only increment duplicatePurchaseUpdatedListenerCountIOS and call
configurePurchaseUpdatedListenerOptionsIOS(true) after emitter.addListener
successfully returns (i.e., move the increment/configure below where
emitterSubscription is non-null), and make the remove implementation robust by
wrapping emitterSubscription.remove() in try/finally so
duplicatePurchaseUpdatedListenerCountIOS is decremented and
configurePurchaseUpdatedListenerOptionsIOS(...) is always called regardless of
remove() errors; update code around includeDuplicateTransactionUpdatesIOS,
emitter.addListener, wrappedListener, emitterSubscription and the returned
remove() to follow this pattern.

---

Nitpick comments:
In `@packages/apple/Sources/OpenIapModule.swift`:
- Around line 1821-1842: The emitPurchaseUpdate function logs and dispatches
even when a duplicate purchase has zero opt-in listeners; after calling
state.snapshotPurchaseUpdated(...) check if listeners.isEmpty and isDuplicate ==
true and return early to avoid the misleading "Emitting purchase update" debug
log and the unnecessary await MainActor.run block. Specifically, in
emitPurchaseUpdate, after obtaining listeners from
state.snapshotPurchaseUpdated, if isDuplicate is true and listeners.count == 0,
call logDuplicatePurchaseUpdate as you already do (or ensure it's already
called) and then short-circuit (return) before the OpenIapLog.debug and
MainActor.run invocation so nothing is dispatched to the main actor.
- Around line 1802-1819: The current logDuplicatePurchaseUpdate function uses
OpenIapLog.warn for both branches which is noisy; change the call to use lower
log levels: call OpenIapLog.info for the branch where listenerCount > 0 (the
"Delivered duplicate purchase-updated event...") and OpenIapLog.debug for the
branch where listenerCount == 0 (the "Suppressed duplicate..." case). Update the
invocation in logDuplicatePurchaseUpdate to choose the appropriate logging
method based on listenerCount while keeping the exact message text and
interpolations (source, productId, transactionId) unchanged.

In `@packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx`:
- Line 8: Add a short JSDoc block above the exported
PurchaseUpdatedListenerOptions component describing its purpose as a docs page
component, noting it is exported and what it returns; include a one-line
description and a `@returns` tag (e.g., React.ReactElement) following repository
JSDoc conventions so the exported function PurchaseUpdatedListenerOptions has
the required public API documentation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3d5220a7-623b-4d53-93b0-7d16583098c9

📥 Commits

Reviewing files that changed from the base of the PR and between b9d704a and 7bb73f1.

⛔ Files ignored due to path filters (6)
  • packages/gql/src/generated/Types.cs is excluded by !**/generated/**
  • packages/gql/src/generated/Types.kt is excluded by !**/generated/**
  • packages/gql/src/generated/Types.swift is excluded by !**/generated/**
  • packages/gql/src/generated/types.dart is excluded by !**/generated/**
  • packages/gql/src/generated/types.gd is excluded by !**/generated/**
  • packages/gql/src/generated/types.ts is excluded by !**/generated/**
📒 Files selected for processing (53)
  • libraries/expo-iap/ios/ExpoIapHelper.swift
  • libraries/expo-iap/ios/ExpoIapModule.swift
  • libraries/expo-iap/src/__mocks__/expo-modules-core.js
  • libraries/expo-iap/src/__tests__/index.test.ts
  • libraries/expo-iap/src/index.ts
  • libraries/expo-iap/src/types.ts
  • libraries/expo-iap/src/useIAP.ts
  • libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift
  • libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
  • libraries/flutter_inapp_purchase/lib/types.dart
  • libraries/godot-iap/addons/godot-iap/godot_iap.gd
  • libraries/godot-iap/addons/godot-iap/types.gd
  • libraries/godot-iap/ios-gdextension/Package.swift
  • libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift
  • libraries/kmp-iap/library/build.gradle.kts
  • libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt
  • libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt
  • libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt
  • libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt
  • libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs
  • libraries/maui-iap/src/OpenIap.Maui/OpenIap.cs
  • libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs
  • libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs
  • libraries/maui-iap/src/OpenIap.Maui/Subject.cs
  • libraries/maui-iap/src/OpenIap.Maui/Types.cs
  • libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs
  • libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt
  • libraries/react-native-iap/ios/HybridRnIap.swift
  • libraries/react-native-iap/src/__tests__/index.test.ts
  • libraries/react-native-iap/src/hooks/useIAP.ts
  • libraries/react-native-iap/src/index.ts
  • libraries/react-native-iap/src/specs/RnIap.nitro.ts
  • libraries/react-native-iap/src/types.ts
  • openiap-versions.json
  • packages/apple/Sources/Helpers/IapState.swift
  • packages/apple/Sources/Models/Types.swift
  • packages/apple/Sources/OpenIapModule+ObjC.swift
  • packages/apple/Sources/OpenIapModule.swift
  • packages/apple/Sources/OpenIapProtocol.swift
  • packages/apple/Sources/OpenIapStore.swift
  • packages/apple/Tests/OpenIapTests.swift
  • packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift
  • packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift
  • packages/docs/openiap-versions.json
  • packages/docs/src/lib/searchData.ts
  • packages/docs/src/pages/docs/events/purchase-updated-listener.tsx
  • packages/docs/src/pages/docs/index.tsx
  • packages/docs/src/pages/docs/types/index.tsx
  • packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx
  • packages/docs/src/pages/docs/updates/releases.tsx
  • packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt
  • packages/gql/src/event.graphql
  • packages/gql/src/type.graphql
✅ Files skipped from review due to trivial changes (8)
  • openiap-versions.json
  • packages/docs/openiap-versions.json
  • libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs
  • libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt
  • libraries/expo-iap/src/mocks/expo-modules-core.js
  • libraries/maui-iap/src/OpenIap.Maui/Subject.cs
  • libraries/flutter_inapp_purchase/lib/types.dart
  • packages/docs/src/pages/docs/events/purchase-updated-listener.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/apple/Tests/OpenIapTests.swift

Comment thread libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
Comment thread libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift
Comment thread libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs Outdated
Comment thread libraries/react-native-iap/ios/HybridRnIap.swift
@hyochan
Copy link
Copy Markdown
Member Author

hyochan commented May 13, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces PurchaseUpdatedListenerOptions across all supported platforms, adding a dedupeTransactionIOS flag that allows iOS listeners to opt-in to duplicate StoreKit transaction replays. While the default behavior remains entitlement-safe by deduplicating transactions by ID, this change provides necessary flexibility for diagnostic scenarios. The review feedback identifies potential thread-safety issues in the React Native iOS implementation where concurrent calls could lead to race conditions during subscription initialization, suggesting the use of existing locks to ensure atomic operations.

Comment thread libraries/react-native-iap/ios/HybridRnIap.swift
Comment thread libraries/react-native-iap/ios/HybridRnIap.swift
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Implements iOS purchase-update deduplication within a connection session (to address StoreKit replay causing multiple purchaseUpdated emissions), while adding an opt-in mechanism for frameworks to still receive duplicates for diagnostics.

Changes:

  • Add PurchaseUpdatedListenerOptions with dedupeTransactionIOS and propagate it through the OpenIAP spec, generated types, and framework bindings.
  • Update openiap-apple to record emitted iOS transaction IDs per connection session and gate both requestPurchase and Transaction.updates emissions through the same dedupe guard.
  • Add docs and tests covering dedupe behavior, opt-in duplicates, and reset/reconnect behavior.

Reviewed changes

Copilot reviewed 53 out of 59 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/gql/src/type.graphql Adds PurchaseUpdatedListenerOptions input to the schema.
packages/gql/src/event.graphql Extends purchaseUpdated subscription to accept options.
packages/gql/src/generated/types.ts Regenerates TypeScript types for listener options + subscription args.
packages/gql/src/generated/Types.swift Regenerates Swift types/signatures for options on purchaseUpdated.
packages/gql/src/generated/Types.kt Regenerates Kotlin types/signatures for options on purchaseUpdated.
packages/gql/src/generated/types.dart Regenerates Dart types/signatures for options on purchaseUpdated.
packages/gql/src/generated/types.gd Regenerates GDScript types for listener options.
packages/gql/src/generated/Types.cs Regenerates C# types/signatures for options on purchaseUpdated.
packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt Mirrors generated Kotlin listener options/types on Google package side for parity.
openiap-versions.json Bumps OpenIAP spec version to 2.0.2.
packages/docs/openiap-versions.json Bumps docs site spec version to 2.0.2.
packages/docs/src/pages/docs/updates/releases.tsx Adds release notes entry describing iOS replay controls and spec bump.
packages/docs/src/pages/docs/events/purchase-updated-listener.tsx Documents duplicate StoreKit replays and opt-in usage across SDKs.
packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx New docs page for PurchaseUpdatedListenerOptions.
packages/docs/src/pages/docs/types/index.tsx Adds type index/redirect entry for the new options type.
packages/docs/src/pages/docs/index.tsx Adds route/nav entry for the new options type docs page.
packages/docs/src/lib/searchData.ts Adds search index entry for the new options docs page.
packages/apple/Sources/Helpers/IapState.swift Adds per-session emission history + per-listener dedupe registration and snapshot filtering.
packages/apple/Sources/OpenIapModule.swift Gates requestPurchase + Transaction.updates through dedupe, adds duplicate logging and duplicate-only listener routing.
packages/apple/Sources/OpenIapProtocol.swift Updates module protocol to accept listener options.
packages/apple/Sources/OpenIapModule+ObjC.swift Adds ObjC overload to configure dedupeTransactionIOS.
packages/apple/Sources/OpenIapStore.swift Updates store wrapper to call new listener signature.
packages/apple/Sources/Models/Types.swift Adds Swift model for PurchaseUpdatedListenerOptions (non-generated runtime types).
packages/apple/Tests/OpenIapTests.swift Adds unit tests for dedupe history, reset behavior, and opt-in listener snapshots.
packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift Updates fake module to match new listener signature.
packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift Updates fake module to match new listener signature.
libraries/react-native-iap/src/types.ts Adds PurchaseUpdatedListenerOptions to public TS types.
libraries/react-native-iap/src/specs/RnIap.nitro.ts Updates Nitro spec to pass options through addPurchaseUpdatedListener.
libraries/react-native-iap/src/index.ts Adds JS-level routing for deduping vs non-deduping listeners and attaches the correct native listener.
libraries/react-native-iap/src/hooks/useIAP.ts Plumbs listener options into useIAP purchase success listener registration.
libraries/react-native-iap/src/tests/index.test.ts Adds tests validating non-deduping listener routes to opt-in native listener.
libraries/react-native-iap/ios/HybridRnIap.swift Adds native listener bucketing and attaches a second OpenIAP subscription for duplicate delivery.
libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt Updates Android Nitro method signature for options (ignored at runtime).
libraries/expo-iap/src/types.ts Adds PurchaseUpdatedListenerOptions to Expo TS types.
libraries/expo-iap/src/index.ts Adds optional per-call options and configures iOS native module for replay behavior.
libraries/expo-iap/src/useIAP.ts Plumbs listener options into useIAP purchase success listener registration.
libraries/expo-iap/src/tests/index.test.ts Adds test validating iOS options configuration call.
libraries/expo-iap/src/mocks/expo-modules-core.js Mocks setPurchaseUpdatedListenerOptions for tests.
libraries/expo-iap/ios/ExpoIapModule.swift Exposes setPurchaseUpdatedListenerOptions to JS via Expo module.
libraries/expo-iap/ios/ExpoIapHelper.swift Stores purchase-updated listener options and reattaches native subscription accordingly.
libraries/flutter_inapp_purchase/lib/types.dart Adds Dart type for PurchaseUpdatedListenerOptions and updates subscription resolver signature.
libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart Adds purchaseUpdatedListenerWithOptions and wires option-setting through the method channel.
libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift Adds native handling for setPurchaseUpdatedListenerOptions and reattaches listener with options.
libraries/maui-iap/src/OpenIap.Maui/OpenIap.cs Adds PurchaseUpdatedWithOptions observable API to MAUI interface.
libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs Implements no-op PurchaseUpdatedWithOptions for unsupported platform.
libraries/maui-iap/src/OpenIap.Maui/Types.cs Adds C# PurchaseUpdatedListenerOptions type and updates resolver signature.
libraries/maui-iap/src/OpenIap.Maui/Subject.cs Adds helper observable/disposable types used by dynamic purchase-updated stream creation.
libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs Creates dynamic purchase-updated observables with options and manages extra native listener tokens.
libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs Implements PurchaseUpdatedWithOptions as an alias to default stream (Android ignores).
libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs Adds iOS binding overload for addPurchaseUpdatedListener:dedupeTransactionIOS:.
libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt Adds Kotlin PurchaseUpdatedListenerOptions and updates resolver signature.
libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt Adds purchaseUpdatedListener(options) API and documents iOS behavior.
libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt Implements options overload as a no-op passthrough on Android.
libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt Implements options overload using callbackFlow + native opt-in listener.
libraries/kmp-iap/library/build.gradle.kts Uses local podspec path for OpenIAP Apple dependency when present (dev convenience).
libraries/godot-iap/addons/godot-iap/types.gd Adds GDScript PurchaseUpdatedListenerOptions type.
libraries/godot-iap/addons/godot-iap/godot_iap.gd Adds API to configure purchase-updated listener options and applies them on iOS.
libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift Adds native iOS method to update dedupe option and reattach listener when connected.
libraries/godot-iap/ios-gdextension/Package.swift Adds local-path OpenIAP dependency fallback for monorepo development.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread libraries/react-native-iap/ios/HybridRnIap.swift Outdated
Comment thread libraries/expo-iap/src/index.ts
Comment thread packages/apple/Sources/Helpers/IapState.swift Outdated
@hyochan
Copy link
Copy Markdown
Member Author

hyochan commented May 13, 2026

/gemini review

@hyochan
Copy link
Copy Markdown
Member Author

hyochan commented May 13, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces PurchaseUpdatedListenerOptions across all supported frameworks to provide control over iOS StoreKit transaction deduplication via the dedupeTransactionIOS flag. By default, the system now suppresses duplicate transaction replays within a session to ensure entitlement safety, while allowing developers to opt-in for diagnostics. Key changes include the implementation of a ring-buffer-based deduplication history in the core Apple package and updated listener management in the bridge layers. Feedback identifies a bug in the Expo implementation where listeners are not correctly removed due to an incorrect return type from the emitter, and suggests an optimization in the React Native iOS bridge to detach native listeners when the last JS-side listener is removed.

Comment thread libraries/expo-iap/src/index.ts
Comment thread libraries/react-native-iap/ios/HybridRnIap.swift Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
libraries/react-native-iap/src/specs/RnIap.nitro.ts (1)

728-735: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update JSDoc to document new options parameter and number return.

The signature changed from void to returning a token and now accepts options, but the JSDoc still only documents listener. Public API consumers (and downstream SDK wrappers) will miss the contract.

📝 Suggested JSDoc update
   /**
-   * Add a listener for purchase updates
-   * `@param` listener - Function to call when a purchase is updated
+   * Add a listener for purchase updates.
+   * `@param` listener - Function invoked when a purchase is updated.
+   * `@param` options - Optional listener-level controls (e.g. iOS duplicate-replay handling).
+   * `@returns` A numeric token to pass to `removePurchaseUpdatedListener` for keyed removal.
    */
   addPurchaseUpdatedListener(
     listener: (purchase: NitroPurchase) => void,
     options?: NitroPurchaseUpdatedListenerOptions,
   ): number;

As per coding guidelines: "Add JSDoc comments for public functions and exported APIs".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/react-native-iap/src/specs/RnIap.nitro.ts` around lines 728 - 735,
The JSDoc for addPurchaseUpdatedListener is outdated: update the comment to
document the new optional options parameter
(NitroPurchaseUpdatedListenerOptions) and that the function returns a numeric
subscription token (number) instead of void; locate the
addPurchaseUpdatedListener declaration and add descriptions for the listener
param, options param, and the numeric return value so public API consumers and
wrappers see the correct contract.
🧹 Nitpick comments (4)
libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart (3)

138-149: 💤 Low value

Doc nit on purchaseUpdatedListener getter / purchaseUpdatedListenerWithOptions.

The doc on purchaseUpdatedListenerWithOptions says "default streams still filter replayed IDs unless they opt out with dedupeTransactionIOS: false." Two things worth clarifying in the docstring:

  1. Each access to purchaseUpdatedListener on iOS now returns a new Stream.multi instance with its own per-subscription history snapshot. Holding the getter result vs. re-accessing it has behavior implications (a freshly-accessed stream snapshots the global history at listen time, so previously delivered txn IDs will be dedupe-filtered immediately).
  2. Re-state that the dedupeTransactionIOS: false option does NOT only affect this stream — _setPurchaseUpdatedListenerOptions reconfigures shared native listener state, so concurrent callers with conflicting options will clobber each other. This is hinted at but worth being explicit about for SDK consumers.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart` around
lines 138 - 149, Update the documentation for the purchaseUpdatedListener getter
and purchaseUpdatedListenerWithOptions to explicitly state two behaviors: (1)
that on iOS each access to the purchaseUpdatedListener getter returns a new
Stream.multi with its own per-subscription history snapshot so re-accessing the
getter vs. holding the stream changes when dedupe filtering is applied, and (2)
that the dedupeTransactionIOS option (set via _setPurchaseUpdatedListenerOptions
or exposed by purchaseUpdatedListenerWithOptions and implemented by
_purchaseUpdatedListenerStreamIOS) changes shared native listener state for the
entire plugin instance, so concurrent callers with conflicting
dedupeTransactionIOS values will overwrite each other.

373-400: ⚡ Quick win

Errors in _handlePurchaseUpdatedCall are swallowed silently — consider surfacing them on the error stream.

If jsonDecode or convertToPurchase throws, the only signal is two debugPrint lines. Callers awaiting purchaseUpdatedListener will simply never see anything, which in this PR's context (where requestPurchase returns null and the outcome is delivered exclusively through the event stream) can leave the app hung. Forwarding the failure as a PurchaseError on _purchaseErrorListener (and/or _purchaseErrorController) would make the failure observable without changing the success path:

     } catch (e, stackTrace) {
       debugPrint(
         '[flutter_inapp_purchase] ERROR in ${call.method}: $e',
       );
       debugPrint('[flutter_inapp_purchase] Stack trace: $stackTrace');
+      _purchaseErrorListener.add(
+        PurchaseError(
+          code: gentype.ErrorCode.ServiceError,
+          message: 'Failed to parse purchase-updated payload: $e',
+        ),
+      );
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart` around
lines 373 - 400, The catch in _handlePurchaseUpdatedCall currently only
debugPrints errors which hides failures; instead, construct a PurchaseError (or
appropriate gentype error object) with the exception and stackTrace and publish
it to the error stream(s) so callers can observe failures — e.g. push the error
into _purchaseErrorController (and/or call _purchaseErrorListener) in the catch
block before/after logging; keep the existing success path unchanged and ensure
you don't rethrow so existing callers still receive no crash but do receive the
error event.

181-220: 💤 Low value

Consider sourcing the dedupe key from the typed iOS field for clarity.

_purchaseUpdatedTransactionIdIOS reads purchase.id, which on iOS happens to equal transactionId today. Reading the typed field directly when the value is a PurchaseIOS would make the contract explicit and immune to any future change in how id is populated for shared Purchase shapes:

 String? _purchaseUpdatedTransactionIdIOS(gentype.Purchase purchase) {
-    final id = purchase.id;
-    return id.isEmpty ? null : id;
+    final id = purchase is gentype.PurchaseIOS
+        ? purchase.transactionId
+        : purchase.id;
+    return id.isEmpty ? null : id;
   }

Not a correctness issue today; just future-proofing the dedupe key against drift between id and transactionId.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart` around
lines 181 - 220, The dedupe key currently uses purchase.id in
_purchaseUpdatedTransactionIdIOS which relies on id matching the iOS
transactionId; change _purchaseUpdatedTransactionIdIOS to prefer the typed iOS
field by detecting when the incoming gentype.Purchase is a PurchaseIOS and
returning its transactionId (falling back to purchase.id if not a PurchaseIOS or
transactionId is null/empty). Update only the logic inside
_purchaseUpdatedTransactionIdIOS (keep the name) to check the concrete type and
return the explicit transactionId for iOS, preserving the existing null/empty
behavior.
libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart (1)

1227-1287: 💤 Low value

Test correctness looks good — flag a small robustness nit on the microtask draining.

The pair of await Future<void>.delayed(Duration.zero) lines (1255–1256) and the one on 1279 are relying on a specific microtask depth in purchaseUpdatedListenerWithOptions (onListen → _setPurchaseUpdatedListenerOptions → invokeMethod → then → listen). If anyone adds another await to that chain (e.g. a second native call for the options), the test will silently start dropping the first replay before the non-deduping subscription is wired up and the assertion nonDedupingPurchases.hasLength(2) will become flaky.

Consider pumping the event loop deterministically instead, e.g. wrap the test body in fakeAsync from package:fake_async or use tester.runAsync so subscription setup is fully drained before injecting messages, or expose a small Future<void> from purchaseUpdatedListenerWithOptions that resolves once the native options are applied and the upstream subscription is in place. Either approach makes this test resilient to future refactors of the listen pipeline.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart`
around lines 1227 - 1287, The two zero-duration delayed awaits are fragile
because they rely on microtask timing; replace them with a deterministic drain
so the non-deduping subscription is guaranteed to be registered before injecting
platform messages: either wrap the test body with fakeAsync from
package:fake_async and call fakeAsync.flushMicrotasks()/flushTimers() before
sending messages, or modify purchaseUpdatedListenerWithOptions (and/or add a
helper like a Future<void> that resolves when _setPurchaseUpdatedListenerOptions
finishes and the upstream listener is attached) and await that Future instead of
using Future.delayed. Ensure you reference purchaseUpdatedListenerWithOptions,
_setPurchaseUpdatedListenerOptions (or the new ready Future) and the
TestDefaultBinaryMessengerBinding message sends when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@libraries/expo-iap/src/__tests__/index.test.ts`:
- Around line 92-121: The test leaves listener subscriptions registered and can
leak state; capture the Subscription returned by purchaseUpdatedListener for
both the default and non-deduping listeners (e.g., store values like
defaultSubscription and nonDedupingSubscription), and ensure you remove them in
a finally block or test teardown (call .remove() on each subscription) so
addListener mock state is cleaned up; update the test cases that use
purchaseUpdatedListener/defaultListener/nonDedupingListener accordingly (also
apply same fix to the tests around lines 123-144).

---

Outside diff comments:
In `@libraries/react-native-iap/src/specs/RnIap.nitro.ts`:
- Around line 728-735: The JSDoc for addPurchaseUpdatedListener is outdated:
update the comment to document the new optional options parameter
(NitroPurchaseUpdatedListenerOptions) and that the function returns a numeric
subscription token (number) instead of void; locate the
addPurchaseUpdatedListener declaration and add descriptions for the listener
param, options param, and the numeric return value so public API consumers and
wrappers see the correct contract.

---

Nitpick comments:
In `@libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart`:
- Around line 138-149: Update the documentation for the purchaseUpdatedListener
getter and purchaseUpdatedListenerWithOptions to explicitly state two behaviors:
(1) that on iOS each access to the purchaseUpdatedListener getter returns a new
Stream.multi with its own per-subscription history snapshot so re-accessing the
getter vs. holding the stream changes when dedupe filtering is applied, and (2)
that the dedupeTransactionIOS option (set via _setPurchaseUpdatedListenerOptions
or exposed by purchaseUpdatedListenerWithOptions and implemented by
_purchaseUpdatedListenerStreamIOS) changes shared native listener state for the
entire plugin instance, so concurrent callers with conflicting
dedupeTransactionIOS values will overwrite each other.
- Around line 373-400: The catch in _handlePurchaseUpdatedCall currently only
debugPrints errors which hides failures; instead, construct a PurchaseError (or
appropriate gentype error object) with the exception and stackTrace and publish
it to the error stream(s) so callers can observe failures — e.g. push the error
into _purchaseErrorController (and/or call _purchaseErrorListener) in the catch
block before/after logging; keep the existing success path unchanged and ensure
you don't rethrow so existing callers still receive no crash but do receive the
error event.
- Around line 181-220: The dedupe key currently uses purchase.id in
_purchaseUpdatedTransactionIdIOS which relies on id matching the iOS
transactionId; change _purchaseUpdatedTransactionIdIOS to prefer the typed iOS
field by detecting when the incoming gentype.Purchase is a PurchaseIOS and
returning its transactionId (falling back to purchase.id if not a PurchaseIOS or
transactionId is null/empty). Update only the logic inside
_purchaseUpdatedTransactionIdIOS (keep the name) to check the concrete type and
return the explicit transactionId for iOS, preserving the existing null/empty
behavior.

In
`@libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart`:
- Around line 1227-1287: The two zero-duration delayed awaits are fragile
because they rely on microtask timing; replace them with a deterministic drain
so the non-deduping subscription is guaranteed to be registered before injecting
platform messages: either wrap the test body with fakeAsync from
package:fake_async and call fakeAsync.flushMicrotasks()/flushTimers() before
sending messages, or modify purchaseUpdatedListenerWithOptions (and/or add a
helper like a Future<void> that resolves when _setPurchaseUpdatedListenerOptions
finishes and the upstream listener is attached) and await that Future instead of
using Future.delayed. Ensure you reference purchaseUpdatedListenerWithOptions,
_setPurchaseUpdatedListenerOptions (or the new ready Future) and the
TestDefaultBinaryMessengerBinding message sends when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: edc39514-36eb-49cf-bc36-aff623ae4397

📥 Commits

Reviewing files that changed from the base of the PR and between 8214866 and 81509fb.

📒 Files selected for processing (12)
  • libraries/expo-iap/ios/ExpoIapHelper.swift
  • libraries/expo-iap/src/__tests__/index.test.ts
  • libraries/expo-iap/src/index.ts
  • libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
  • libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart
  • libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt
  • libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt
  • libraries/react-native-iap/ios/HybridRnIap.swift
  • libraries/react-native-iap/src/__tests__/index.test.ts
  • libraries/react-native-iap/src/index.ts
  • libraries/react-native-iap/src/specs/RnIap.nitro.ts
  • packages/docs/src/pages/docs/updates/releases.tsx
🚧 Files skipped from review as they are similar to previous changes (5)
  • libraries/react-native-iap/src/tests/index.test.ts
  • libraries/react-native-iap/src/index.ts
  • libraries/expo-iap/src/index.ts
  • libraries/expo-iap/ios/ExpoIapHelper.swift
  • libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt

Comment thread libraries/expo-iap/src/__tests__/index.test.ts
@hyochan
Copy link
Copy Markdown
Member Author

hyochan commented May 13, 2026

/gemini review

@hyochan
Copy link
Copy Markdown
Member Author

hyochan commented May 13, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces PurchaseUpdatedListenerOptions across all supported frameworks (Expo, Flutter, Godot, KMP, MAUI, and React Native) to allow developers to opt out of iOS StoreKit transaction deduplication for diagnostics. The core logic is implemented in the openiap-apple package using a ring-buffer-based emission history. Feedback highlights an opportunity to implement a reference counter in the Flutter SDK to reset native deduplication state when opt-out listeners are removed, as well as a performance optimization for the Expo SDK's emission history to avoid O(n) operations when re-indexing arrays.

Comment thread libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
Comment thread libraries/expo-iap/src/index.ts Outdated
@hyochan
Copy link
Copy Markdown
Member Author

hyochan commented May 13, 2026

/gemini review

@hyochan
Copy link
Copy Markdown
Member Author

hyochan commented May 13, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements the OpenIAP Spec 2.0.2, introducing PurchaseUpdatedListenerOptions and the dedupeTransactionIOS flag across the entire ecosystem. This allows iOS developers to opt-in to receiving duplicate StoreKit transaction replays for diagnostics, while the default behavior remains to deduplicate events by transaction ID to prevent redundant entitlement processing. The changes include updates to the core Apple and Google packages, all framework SDKs (React Native, Expo, Flutter, Godot, KMP, and MAUI), and the documentation. Feedback was provided regarding the Swift implementation in OpenIapModule.swift, suggesting that the transaction.finish() call should be gated by the deduplication check to avoid redundant operations on duplicate transactions.

Comment thread packages/apple/Sources/OpenIapModule.swift
@hyochan
Copy link
Copy Markdown
Member Author

hyochan commented May 13, 2026

/gemini review

@hyochan
Copy link
Copy Markdown
Member Author

hyochan commented May 13, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces PurchaseUpdatedListenerOptions across all supported frameworks to allow developers to control iOS StoreKit transaction deduplication. The core logic in the openiap-apple package now tracks transaction IDs using a ring buffer to prevent duplicate emissions by default, while allowing an opt-out via the dedupeTransactionIOS flag for diagnostic purposes. The React Native implementation was refactored to use token-based listener management, and various debug logs were removed from the KMP iOS implementation. Feedback was provided regarding the extraction of optional boolean values from the Nitro bridge in the React Native iOS implementation, noting that the current approach assumes a specific enum structure which should be verified against the generated code to prevent potential runtime crashes.

Comment thread libraries/react-native-iap/ios/HybridRnIap.swift Outdated
@hyochan
Copy link
Copy Markdown
Member Author

hyochan commented May 13, 2026

/gemini review

@hyochan
Copy link
Copy Markdown
Member Author

hyochan commented May 13, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces PurchaseUpdatedListenerOptions to allow fine-grained control over purchase update event delivery, specifically addressing iOS StoreKit transaction replay behavior. It adds an optional dedupeTransactionIOS flag across all supported platforms (React Native, Flutter, KMP, MAUI, Godot, and Swift/Kotlin native modules), enabling developers to opt-in to receiving duplicate transaction events for diagnostic purposes. The implementation includes native-side deduplication logic and updates the documentation to reflect these changes. I have no feedback to provide as there were no review comments to evaluate.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
libraries/expo-iap/src/index.ts (1)

263-266: ⚡ Quick win

Document the new purchaseUpdatedListener option contract.

This exported API now has iOS-only replay semantics, but there is still no JSDoc here describing the default for dedupeTransactionIOS, its session scope, or how it interacts with reconnects. A short doc block would make this discoverable from the JS surface instead of forcing consumers into the implementation.

Possible doc stub
+/**
+ * Listen for purchase updates.
+ *
+ * `@param` listener Callback invoked for each normalized purchase update.
+ * `@param` options iOS-only listener options. `dedupeTransactionIOS` defaults to
+ *   `true`; pass `false` to allow duplicate transaction replays within the
+ *   current connection session.
+ */
 export const purchaseUpdatedListener = (
   listener: (event: Purchase) => void,
   options?: PurchaseUpdatedListenerOptions | null,
 ) => {

As per coding guidelines, "Add JSDoc comments for public functions and exported APIs" and "Write clear comments and documentation for complex logic and public APIs."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/expo-iap/src/index.ts` around lines 263 - 266, Add a JSDoc block
above the exported purchaseUpdatedListener function that documents the
PurchaseUpdatedListenerOptions contract, explicitly stating that
dedupeTransactionIOS is an iOS-only option, its default value (e.g.,
true/false), that it applies to the current JS session only (i.e., it
deduplicates replayed transactions for the lifetime of the process or until
listeners are removed), and how it behaves across reconnects (e.g., whether
prior transaction IDs are remembered or discarded on reconnect). Reference the
PurchaseUpdatedListenerOptions type and the dedupeTransactionIOS field in the
comment, and briefly describe the iOS-only "replay" semantics so callers
understand when they should set dedupeTransactionIOS to override the default.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@libraries/expo-iap/src/index.ts`:
- Around line 313-315: The native purchaseUpdatedOptions resets to default
during cleanup (cleanupListenersLocked), so after reconnect initConnection must
reapply the non-deduping setting if there are active non-deduping subscribers;
update initConnection (or the post-connection success path) to check
nonDedupingPurchaseUpdatedListenerCountIOS and call
configurePurchaseUpdatedListenerOptionsIOS(false) when that count > 0 (and
otherwise ensure deduping true), so the JS counter and native state remain in
sync across endConnection()/initConnection() cycles.

---

Nitpick comments:
In `@libraries/expo-iap/src/index.ts`:
- Around line 263-266: Add a JSDoc block above the exported
purchaseUpdatedListener function that documents the
PurchaseUpdatedListenerOptions contract, explicitly stating that
dedupeTransactionIOS is an iOS-only option, its default value (e.g.,
true/false), that it applies to the current JS session only (i.e., it
deduplicates replayed transactions for the lifetime of the process or until
listeners are removed), and how it behaves across reconnects (e.g., whether
prior transaction IDs are remembered or discarded on reconnect). Reference the
PurchaseUpdatedListenerOptions type and the dedupeTransactionIOS field in the
comment, and briefly describe the iOS-only "replay" semantics so callers
understand when they should set dedupeTransactionIOS to override the default.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 54b61c95-fa69-4fa8-abca-8256b051c58a

📥 Commits

Reviewing files that changed from the base of the PR and between 8214866 and 0cbb598.

📒 Files selected for processing (14)
  • libraries/expo-iap/ios/ExpoIapHelper.swift
  • libraries/expo-iap/src/__mocks__/expo-modules-core.js
  • libraries/expo-iap/src/__tests__/index.test.ts
  • libraries/expo-iap/src/index.ts
  • libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
  • libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart
  • libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt
  • libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt
  • libraries/react-native-iap/ios/HybridRnIap.swift
  • libraries/react-native-iap/src/__tests__/index.test.ts
  • libraries/react-native-iap/src/index.ts
  • libraries/react-native-iap/src/specs/RnIap.nitro.ts
  • packages/apple/Sources/OpenIapModule.swift
  • packages/docs/src/pages/docs/updates/releases.tsx
✅ Files skipped from review due to trivial changes (1)
  • packages/docs/src/pages/docs/updates/releases.tsx
🚧 Files skipped from review as they are similar to previous changes (11)
  • libraries/expo-iap/src/mocks/expo-modules-core.js
  • libraries/expo-iap/src/tests/index.test.ts
  • libraries/react-native-iap/src/specs/RnIap.nitro.ts
  • libraries/expo-iap/ios/ExpoIapHelper.swift
  • libraries/react-native-iap/ios/HybridRnIap.swift
  • libraries/react-native-iap/src/tests/index.test.ts
  • libraries/react-native-iap/src/index.ts
  • libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt
  • packages/apple/Sources/OpenIapModule.swift
  • libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
  • libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt

Comment thread libraries/expo-iap/src/index.ts
@hyochan
Copy link
Copy Markdown
Member Author

hyochan commented May 13, 2026

/gemini review

@hyochan
Copy link
Copy Markdown
Member Author

hyochan commented May 13, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces PurchaseUpdatedListenerOptions to allow fine-grained control over iOS StoreKit transaction replay delivery. It adds a dedupeTransactionIOS flag to the purchaseUpdatedListener across all supported platforms (React Native, Flutter, KMP, MAUI, Godot, and Swift/Kotlin native), enabling developers to opt-in to receiving duplicate events for diagnostics. The implementation includes native-side deduplication logic in IapState and updates the OpenIAP specification to version 2.0.2. No review comments were provided, so I have no feedback to offer.

@hyochan hyochan merged commit 37646d4 into main May 13, 2026
18 checks passed
@hyochan hyochan deleted the fix/apple-purchase-update-dedupe branch May 13, 2026 11:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐛 bug Something isn't working 📱 iOS Related to iOS react-native-iap react-native-iap library

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants