Skip to content
This repository was archived by the owner on Apr 26, 2026. It is now read-only.

fix(listeners): use singleton native listener to prevent iOS removeAll() bug#3164

Merged
hyochan merged 3 commits into
mainfrom
fix/listener-singleton-3150
Mar 10, 2026
Merged

fix(listeners): use singleton native listener to prevent iOS removeAll() bug#3164
hyochan merged 3 commits into
mainfrom
fix/listener-singleton-3150

Conversation

@hyochan

@hyochan hyochan commented Mar 10, 2026

Copy link
Copy Markdown
Owner

Summary

  • Fix critical bug where iOS removePurchaseUpdatedListener called removeAll() instead of removing only the specific listener, silently wiping ALL registered listeners
  • Replace per-listener native registration with singleton native handler + JS-level Set fan-out for all 5 listener types
  • remove() now only deletes from the JS Set — other listeners remain intact regardless of iOS native behavior

Root Cause

When multiple useIAP hooks were active (e.g., a persistent top-level component + a screen-level component), unmounting one component called removePurchaseUpdatedListener on iOS which triggered purchaseUpdatedListeners.removeAll(). This wiped all listeners including the ones from the still-mounted component, causing onPurchaseSuccess to silently stop firing.

Android was unaffected because it correctly used .remove(listener) instead of .removeAll().

Before (broken)

Component A: useIAP → registers listener A
Component B: useIAP → registers listener B
Component B unmounts → iOS removeAll() → listener A also gone ❌
User purchases → no listener to receive → purchase lost

After (fixed)

Component A: useIAP → adds cbA to JS Set
Component B: useIAP → adds cbB to JS Set  
  (native singleton already attached, no duplicate registration)
Component B unmounts → Set.delete(cbB) → cbA still in Set ✅
User purchases → native singleton → fans out to cbA ✅

Changes

src/index.ts

  • Replace WeakMap per-listener tracking with module-level Set per event type
  • Register a single native handler that fans out to all JS listeners in the Set
  • remove() only deletes from JS Set (never calls native remove)
  • Add resetListenerState() called in endConnection() for clean re-registration
  • Applied to all 5 listener types: purchaseUpdated, purchaseError, promotedProduct, userChoiceBilling, developerProvidedBilling

src/__tests__/index.test.ts

  • Update tests to verify singleton native registration (1 call, not N)
  • Verify JS-level removal: listener stops receiving after remove()
  • Verify independent removal: removing one listener doesn't affect others

Test plan

  • yarn typecheck passes
  • yarn lint passes
  • yarn jest — 251 tests pass

Closes #3150

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a public way to fully clear and reset event-listener state to ensure clean reconnections.
  • Refactor

    • Centralized event dispatch so a single native callback fans out to multiple JS listeners for consistent broadcasting and error handling.
  • Tests

    • Updated tests to validate single-handler broadcasting, JS-level removal behavior, reconnection/re-registration, and normalized error forwarding.
  • Documentation

    • Clarified PR review workflow to require explicit POST when replying to individual review comments.

…l() bug

When multiple useIAP instances were active (or a single instance
unmounted/remounted during navigation), iOS native
removePurchaseUpdatedListener called removeAll() instead of removing
only the specific listener. This silently wiped ALL registered
listeners, causing onPurchaseSuccess to never fire — users paid but
the app never acknowledged the purchase.

The fix replaces per-listener native registration with a singleton
native handler per event type and JS-level Set-based fan-out.
remove() now only deletes from the JS Set, so other listeners remain
intact regardless of iOS native behavior.

Applied to all 5 listener types: purchaseUpdated, purchaseError,
promotedProduct, userChoiceBilling, developerProvidedBilling.

Closes #3150

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Mar 10, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 81fe920d-f5f0-44a2-a224-24e5eb0f0702

📥 Commits

Reviewing files that changed from the base of the PR and between 60f2092 and a268bf6.

📒 Files selected for processing (1)
  • .claude/commands/review-pr.md

📝 Walkthrough

Walkthrough

Refactors listener management to use a single native callback per event type that fans out to multiple JS listeners via Sets; adds resetListenerState() and clears listener state during endConnection. Tests and docs updated to reflect singleton native handlers and JS-only removal behavior.

Changes

Cohort / File(s) Summary
Core listener refactor
src/index.ts
Replace per-subscriber WeakMap tracking with per-event JS Sets and a single native listener per event; add and export resetListenerState(): void; centralize native→JS fan-out for purchases, errors, promoted products, user-choice billing, and developer billing; clear listener state on endConnection.
Tests updated for singleton model
src/__tests__/index.test.ts
Update tests to expect one native handler per event regardless of JS listener count; simulate events via singleton native handler; verify JS-only removal (native not deregistered) and re-registration after endConnection.
CLI / workflow docs
.claude/commands/review-pr.md
Require explicit HTTP method (-X POST) when replying to individual review comments via gh api; add critical note clarifying endpoint and method requirements.
Metadata / packaging
package.json, manifest_file
Non-functional metadata/packaging updates accompanying the implementation changes.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    rect rgba(200,200,255,0.5)
    participant JS_App as JS App
    participant ListenerMgr as Listener Manager (src/index.ts)
    participant Native as Native Layer
    end
    JS_App->>ListenerMgr: addPurchaseUpdatedListener(cb1)
    JS_App->>ListenerMgr: addPurchaseUpdatedListener(cb2)
    ListenerMgr->>Native: registerSingleNativePurchaseListener()  %% on first JS listener
    Native->>ListenerMgr: nativePurchaseEvent(purchase)
    ListenerMgr->>JS_App: invoke cb1(purchase)
    ListenerMgr->>JS_App: invoke cb2(purchase)
    JS_App->>ListenerMgr: removePurchaseUpdatedListener(cb1)     %% only JS Set updated
    JS_App->>ListenerMgr: endConnection()
    ListenerMgr->>ListenerMgr: resetListenerState()
    JS_App->>ListenerMgr: initConnection() -> native listener re-registered on next add
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

🛠 refactor

Poem

🐰 I hop between callbacks, one bell for many ears,
A single native chime sends news to all my peers.
I clear my soft paws at every end, then spring anew,
Fans-out the carrots of events — hooray, the purchases hop through! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately identifies the primary fix: implementing a singleton native listener to resolve an iOS removeAll() bug that was clearing all listeners.
Linked Issues check ✅ Passed The PR implements a core architectural fix (singleton pattern with JS-level fan-out) to prevent listener removal bugs, directly addressing the root cause of missing purchase notifications in issue #3150.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the listener registration/removal mechanism and test coverage; documentation update to review-pr.md is a minor supporting change.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/listener-singleton-3150

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request resolves a critical issue in the iOS implementation of react-native-iap where removing a purchase update listener would inadvertently remove all listeners, causing purchase events to be missed. The solution replaces the previous per-listener native registration with a singleton native handler and JS-level Set fan-out, ensuring that listeners can be individually removed without affecting others. This change also includes updates to the testing suite to verify the new singleton behavior and independent removal of listeners.

Highlights

  • iOS Listener Bug Fix: Addresses a critical bug on iOS where removePurchaseUpdatedListener inadvertently removed all listeners, leading to lost purchase events.
  • Singleton Native Listener: Implements a singleton native listener pattern with JavaScript-level fan-out for all five listener types, ensuring only one native listener is registered per event type.
  • JS-Level Listener Management: Manages listeners at the JavaScript level using Set objects, allowing individual listeners to be removed without affecting others.
  • Listener State Reset: Introduces a resetListenerState() function to clear all JS-level listener tracking during endConnection(), ensuring clean re-registration upon subsequent initializations.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • src/tests/index.test.ts
    • Updated tests to verify singleton native registration.
    • Added tests to verify JS-level removal of listeners.
    • Added tests to ensure independent removal of listeners without affecting others.
  • src/index.ts
    • Refactored event listener implementation to use singleton native listeners with JS-level fan-out.
    • Added resetListenerState() function to reset listener state during endConnection().
    • Updated listener management using Set objects for individual listener control.
Activity
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@codecov

codecov Bot commented Mar 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 63.24786% with 43 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.90%. Comparing base (ed55e2a) to head (a268bf6).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
src/index.ts 63.24% 43 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3164      +/-   ##
==========================================
+ Coverage   68.84%   68.90%   +0.05%     
==========================================
  Files           9        9              
  Lines        1778     1791      +13     
  Branches      587      584       -3     
==========================================
+ Hits         1224     1234      +10     
- Misses        549      552       +3     
  Partials        5        5              
Flag Coverage Δ
library 68.90% <63.24%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/index.ts 70.27% <63.24%> (+0.09%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Copy link
Copy Markdown
Contributor

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 critical fix for an iOS bug where removing one IAP listener would inadvertently remove all listeners, by refactoring the event listener logic to use a singleton native listener for each event type with a JavaScript-level Set to manage individual listeners. While this is a significant improvement for reliability, a security issue was identified where sensitive purchase data and PII are logged to the system console in error conditions, which needs to be addressed by sanitizing the logged objects. Additionally, consider improving the robustness of the endConnection logic by ensuring the listener state is only reset after the native connection is successfully closed.

Comment thread src/index.ts Outdated
Comment on lines 1178 to 1179
resetListenerState();
return await IAP.instance.endConnection();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

To improve robustness, it's better to reset the listener state after the native endConnection call succeeds. If IAP.instance.endConnection() fails (by throwing or returning false), the current implementation would leave the JS state reset while the native state might not be, causing an inconsistency. Moving resetListenerState() to after the await and checking for success ensures that the JS state is only cleared if the native operation is successful.

For example:

export const endConnection: MutationField<'endConnection'> = async () => {
  try {
    if (!iapRef) return true;
    const success = await IAP.instance.endConnection();
    if (success) {
      resetListenerState();
    }
    return success;
  } catch (error) {
    // ...
  }
};

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 60f2092 — resetListenerState() now runs after endConnection() succeeds, so JS state is preserved if the native call fails.

Comment thread src/index.ts
Comment on lines +218 to +221
RnIapConsole.error(
'Invalid purchase data received from native:',
nitroPurchase,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-medium medium

The library logs the entire nitroPurchase object to console.error when validation fails in purchaseUpdateNativeHandler. This object can contain sensitive information such as purchaseToken, appAccountToken (PII), and other transaction details. Since RnIapConsole.error is always enabled, this information is written to the system logs in production environments. An attacker with access to system logs could potentially exfiltrate sensitive purchase tokens or PII.

Recommendation: Sanitize the nitroPurchase object before logging it. Only log non-sensitive fields like productId and transactionId to help with debugging.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 60f2092 — error logs now only include productId/id instead of the full purchase object.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
src/index.ts (1)

396-412: Use canonical listener payload types instead of any / local interfaces.

These callback payloads are part of the public TS API, but one is still any and the other is declared ad hoc in src/index.ts. Please move them into src/types.ts and import them here with import type so the public surface has one canonical source.

As per coding guidelines, "When declaring API params/results in JS/TS modules, import canonical types from src/types.ts rather than creating ad-hoc interfaces."

Also applies to: 481-503

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.ts` around lines 396 - 412, Replace the ad-hoc/any listener payload
types with canonical exported types from src/types.ts: define (or locate) a
UserChoiceBillingPayload (and any other payload used around lines ~481-503) in
src/types.ts, export it, then in this file import type {
UserChoiceBillingPayload, NitroUserChoiceBillingListener } from 'src/types';
update the type of NitroUserChoiceBillingListener and the listener callbacks
(userChoiceBillingJsListeners, userChoiceBillingNativeHandler,
userChoiceBillingListenerAndroid) to use those imported types instead of any or
locally-declared interfaces so the public API types are centralized and
consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/index.ts`:
- Around line 260-272: resetListenerState currently clears all JS listener
bookkeeping immediately, which can lose references if the native endConnection()
fails; change the flow so resetListenerState is only invoked after
endConnection() has completed successfully (i.e., wait for endConnection() to
resolve before calling resetListenerState), or make resetListenerState async and
have endConnection() await its success before clearing; specifically ensure the
flags purchaseUpdateNativeAttached, purchaseErrorNativeAttached,
promotedProductNativeAttached, userChoiceBillingNativeAttached,
developerProvidedBillingNativeAttached and the listener maps
purchaseUpdateJsListeners, purchaseErrorJsListeners, promotedProductJsListeners,
userChoiceBillingJsListeners, developerProvidedBillingJsListeners are cleared
only after a confirmed native teardown from endConnection() (apply same change
at the other occurrence around the lines referenced 1175-1179).
- Around line 227-235: The purchaseErrorNativeHandler is discarding the native
productId by hardcoding productId to undefined; update
purchaseErrorNativeHandler so the normalized PurchaseError preserves the native
productId (e.g., productId: error.productId or falling back to undefined) while
still normalizing the code via normalizeErrorCodeFromNative, then emit that
normalized object to purchaseErrorJsListeners; ensure the property name matches
the PurchaseError type.
- Around line 279-292: The listener attachment blocks (e.g., the purchaseUpdated
listener using purchaseUpdateNativeAttached and
IAP.instance.addPurchaseUpdatedListener with purchaseUpdateNativeHandler)
swallow the listener when Nitro isn't ready and never replay it; modify the
module to queue pending native listener attachments when toErrorMessage(e)
indicates "Nitro runtime not installed" (store the listener identity and type),
and then, inside initConnection() (or wherever IAP.instance becomes available),
iterate that pending queue to call the corresponding IAP.instance.add*Listener
methods and flip the associated *Attached flags (purchaseUpdateNativeAttached
and the analogous flags for other blocks at lines ~307, ~350, ~422, ~513).
Ensure you only attach each queued listener once and clear the queue after
replay.
- Around line 211-216: The native handler (purchaseUpdateNativeHandler)
currently calls each function in purchaseUpdateJsListeners directly so a thrown
exception from one listener aborts the loop; change it to iterate over a shallow
snapshot of purchaseUpdateJsListeners (e.g., Array.from(...) or
[...purchaseUpdateJsListeners]) and invoke each listener inside its own
try/catch so failures are caught and logged per-listener (include identifying
context like the listener reference and the convertedPurchase), leaving other
listeners to run; apply the same pattern to the equivalent native handlers /
listener arrays referenced elsewhere in this file (the other purchase/event
handler blocks noted in the review).

---

Nitpick comments:
In `@src/index.ts`:
- Around line 396-412: Replace the ad-hoc/any listener payload types with
canonical exported types from src/types.ts: define (or locate) a
UserChoiceBillingPayload (and any other payload used around lines ~481-503) in
src/types.ts, export it, then in this file import type {
UserChoiceBillingPayload, NitroUserChoiceBillingListener } from 'src/types';
update the type of NitroUserChoiceBillingListener and the listener callbacks
(userChoiceBillingJsListeners, userChoiceBillingNativeHandler,
userChoiceBillingListenerAndroid) to use those imported types instead of any or
locally-declared interfaces so the public API types are centralized and
consistent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b7538016-66c0-4e4e-8e25-e24dc4a8cb70

📥 Commits

Reviewing files that changed from the base of the PR and between e2acf36 and d430ec9.

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

Comment thread src/index.ts
Comment thread src/index.ts
Comment on lines +227 to +235
const purchaseErrorNativeHandler: NitroPurchaseErrorListener = (error) => {
const normalized: PurchaseError = {
code: normalizeErrorCodeFromNative(error.code),
message: error.message,
productId: undefined,
};
for (const listener of purchaseErrorJsListeners) {
listener(normalized);
}

@coderabbitai coderabbitai Bot Mar 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Preserve productId when normalizing purchase errors.

Line 231 hardcodes productId to undefined, so SKU-specific failures lose the identifier apps use to correlate the error back to the request. The native side still carries productId, so this looks like a regression.

🔧 Minimal fix
 const purchaseErrorNativeHandler: NitroPurchaseErrorListener = (error) => {
   const normalized: PurchaseError = {
     code: normalizeErrorCodeFromNative(error.code),
     message: error.message,
-    productId: undefined,
+    productId: error.productId,
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const purchaseErrorNativeHandler: NitroPurchaseErrorListener = (error) => {
const normalized: PurchaseError = {
code: normalizeErrorCodeFromNative(error.code),
message: error.message,
productId: undefined,
};
for (const listener of purchaseErrorJsListeners) {
listener(normalized);
}
const purchaseErrorNativeHandler: NitroPurchaseErrorListener = (error) => {
const normalized: PurchaseError = {
code: normalizeErrorCodeFromNative(error.code),
message: error.message,
productId: error.productId,
};
for (const listener of purchaseErrorJsListeners) {
listener(normalized);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.ts` around lines 227 - 235, The purchaseErrorNativeHandler is
discarding the native productId by hardcoding productId to undefined; update
purchaseErrorNativeHandler so the normalized PurchaseError preserves the native
productId (e.g., productId: error.productId or falling back to undefined) while
still normalizing the code via normalizeErrorCodeFromNative, then emit that
normalized object to purchaseErrorJsListeners; ensure the property name matches
the PurchaseError type.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Investigated — NitroPurchaseResult (the native error type) does not carry a productId field, only responseCode/debugMessage/code/message/purchaseToken. So undefined is correct here; there's no native productId to preserve.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the NitroPurchaseErrorListener / NitroPurchaseResult type in the Nitro spec
rg -n "NitroPurchaseResult\|PurchaseErrorListener\|purchaseError\|NitroPurchaseError" --type=ts -C3

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Find the Nitro spec file and check the error-related type definitions
fd "RnIap.nitro" --type f

Length of output: 24


🏁 Script executed:

#!/bin/bash
# Check the error listener and related types in the Nitro spec
grep -n "purchaseError\|PurchaseError\|NitroPurchase\|productId\|responseCode\|debugMessage\|purchaseToken" src/specs/RnIap.nitro.ts | head -60

Length of output: 2410


🏁 Script executed:

#!/bin/bash
# Show the full NitroPurchaseResult interface definition
sed -n '329,345p' src/specs/RnIap.nitro.ts

Length of output: 437


@hyochan, confirmed — NitroPurchaseResult (the type flowing into addPurchaseErrorListener) is defined as:

export interface NitroPurchaseResult {
  responseCode: number;
  debugMessage?: string;
  code: string;
  message: string;
  purchaseToken?: string;
}

No productId field exists on that interface, so productId: undefined is the correct and only option. My original suggestion was wrong — error.productId would be a TypeScript type error. Thanks for digging into this!

🐇
sniffs the spec file
productId? Never heard of her.


✏️ Learnings added
Learnt from: hyochan
Repo: hyochan/react-native-iap PR: 3164
File: src/index.ts:231-243
Timestamp: 2026-03-10T18:42:37.865Z
Learning: In `src/index.ts`, the `purchaseErrorNativeHandler` correctly sets `productId: undefined` in the normalized `PurchaseError` because the native error type `NitroPurchaseResult` (defined in `src/specs/RnIap.nitro.ts`) does not carry a `productId` field — it only has `responseCode`, `debugMessage`, `code`, `message`, and `purchaseToken`. Suggesting `error.productId` would be a TypeScript type error.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: hyochan
Repo: hyochan/react-native-iap PR: 3157
File: ios/HybridRnIap.swift:870-872
Timestamp: 2026-03-04T06:38:51.213Z
Learning: In `ios/HybridRnIap.swift`, the `removePromotedProductListenerIOS`, `removePurchaseUpdatedListener`, and `removePurchaseErrorListener` methods intentionally call `removeAll()` rather than removing a single listener by identity. This is because Swift closures passed through the Nitro bridge cannot be identity-compared — each bridge call wraps the closure in a new object. Per-listener removal is instead handled by the JS layer's WeakMap-based listener management. An ID/token-based approach would require changes to the Nitro interface and is deferred as a separate enhancement.

Learnt from: CR
Repo: hyochan/react-native-iap PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-18T15:04:06.079Z
Learning: Applies to example/**/*.{ts,tsx} : For `requestPurchase` in the `useIAP` hook, use the `onPurchaseSuccess` callback to receive purchase results, NOT the return value.

Learnt from: CR
Repo: hyochan/react-native-iap PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-18T15:04:06.079Z
Learning: Applies to src/specs/RnIap.nitro.ts : The Nitro interface definition in `src/specs/RnIap.nitro.ts` contains the contract between TypeScript and native implementations. Include ALL native method declarations here.

Learnt from: CR
Repo: hyochan/react-native-iap PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-18T15:04:06.079Z
Learning: Applies to example/**/*.{ts,tsx} : Inside the `useIAP` hook, most methods return `Promise<void>` and update internal state. Do not design examples that expect returned data from methods like `fetchProducts`, `requestPurchase`, `getAvailablePurchases`. Consume state from the hook instead.

Learnt from: CR
Repo: hyochan/react-native-iap PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-18T15:04:06.079Z
Learning: Applies to example/**/*.{ts,tsx} : Always use `useIAP` hook for error handling - errors are automatically normalized to `ErrorCode` enum. Use helper functions `isUserCancelledError()` and `getUserFriendlyErrorMessage()` for common error checks.

Learnt from: hyochan
Repo: hyochan/react-native-iap PR: 2999
File: android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt:644-660
Timestamp: 2025-09-13T01:07:18.841Z
Learning: In Android IAP error handling: purchaseToken and productId are distinct properties - purchaseToken identifies a completed purchase transaction (should be null in error cases), while productId is the product SKU for context

Learnt from: hyochan
Repo: hyochan/react-native-iap PR: 3002
File: docs/docs/getting-started/setup-ios.md:55-60
Timestamp: 2025-09-14T00:13:04.055Z
Learning: The validateReceipt function from useIAP hook in react-native-iap expects a transaction identifier as parameter, which is accessed via purchase.id (not purchase.productId). This is confirmed by the maintainer hyochan and aligns with the library's migration from purchase.transactionId to purchase.id.

Learnt from: hyochan
Repo: hyochan/react-native-iap PR: 3162
File: android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt:696-697
Timestamp: 2026-03-09T18:42:11.197Z
Learning: In `android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt`, `finishTransaction` returns `Variant_Boolean_NitroPurchaseResult.First(true)` when `params.android` is null. This is intentional cross-platform behavior: iOS calls `finishTransaction` with iOS-only params, so missing Android params is a valid no-op success case for the iOS stub path on Android. Platform detection to distinguish iOS stub calls from genuine Android caller errors is a future improvement.

Comment thread src/index.ts
Comment thread src/index.ts
…nnection

Address PR review feedback:
- Wrap each listener callback in try/catch so one throwing subscriber
  cannot prevent other listeners from receiving the event
- Move resetListenerState() after native endConnection() succeeds,
  so JS listener state is preserved if endConnection fails
- Sanitize error logs to avoid leaking full purchase objects

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/index.ts (1)

412-430: Consider using a strongly-typed interface instead of any.

userChoiceBillingJsListeners and the listener parameter use any for the details type. For type safety and API consistency, consider defining or importing a canonical type (e.g., UserChoiceBillingDetailsAndroid) from src/types.ts, similar to how DeveloperProvidedBillingDetailsAndroid is defined at line 518.

As per coding guidelines: "When declaring API params/results in JS/TS modules, import canonical types from src/types.ts rather than creating ad-hoc interfaces."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.ts` around lines 412 - 430, Replace the ad-hoc any types with the
canonical type from src/types.ts: import and use UserChoiceBillingDetailsAndroid
(or the appropriate exported name) for the Set, for
userChoiceBillingNativeHandler's details parameter, and for the listener
parameter in userChoiceBillingListenerAndroid; also ensure
NitroUserChoiceBillingListener (referenced by userChoiceBillingNativeHandler)
matches or is updated to accept UserChoiceBillingDetailsAndroid so the types are
consistent across userChoiceBillingJsListeners, userChoiceBillingNativeHandler,
and userChoiceBillingListenerAndroid.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/index.ts`:
- Around line 412-430: Replace the ad-hoc any types with the canonical type from
src/types.ts: import and use UserChoiceBillingDetailsAndroid (or the appropriate
exported name) for the Set, for userChoiceBillingNativeHandler's details
parameter, and for the listener parameter in userChoiceBillingListenerAndroid;
also ensure NitroUserChoiceBillingListener (referenced by
userChoiceBillingNativeHandler) matches or is updated to accept
UserChoiceBillingDetailsAndroid so the types are consistent across
userChoiceBillingJsListeners, userChoiceBillingNativeHandler, and
userChoiceBillingListenerAndroid.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aa95d224-1a27-4a10-aaa3-ca4528091c4d

📥 Commits

Reviewing files that changed from the base of the PR and between d430ec9 and 60f2092.

📒 Files selected for processing (1)
  • src/index.ts

Without -X POST, gh api defaults to GET on the replies endpoint
which returns 404.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@hyochan hyochan merged commit 4d9f743 into main Mar 10, 2026
9 checks passed
@hyochan hyochan deleted the fix/listener-singleton-3150 branch March 10, 2026 18:55
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

🛠 bugfix All kinds of bug fixes 📱 iOS Related to iOS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[iOS/Android] - High volume of purchases not being handled/notified

1 participant