From 438c7ff1a2126bd139d36ee7b62fcd1426498d94 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Thu, 12 Feb 2026 12:13:33 -0600 Subject: [PATCH 1/8] fix: handle NativeEventEmitter construction failure in React Native 0.79+ Fixes #1298 React Native 0.79 introduced breaking changes to NativeEventEmitter that made the constructor stricter. When the native module doesn't have proper event emitter methods, construction throws an error causing the app to crash with "undefined is not a function in eventEmitter.addListener". This change wraps the NativeEventEmitter construction in a try-catch block to gracefully handle failures. When construction fails: - A clear warning is logged explaining the impact - eventEmitter is set to null - Core SDK functionality (purchases, customer info) continues to work - Only event listeners (customer info updates, promo purchases) are affected - Existing optional chaining (eventEmitter?.addListener) handles null safely All 189 existing tests pass with this change. --- src/purchases.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/purchases.ts b/src/purchases.ts index d973d23a6..e02c4a54d 100644 --- a/src/purchases.ts +++ b/src/purchases.ts @@ -95,8 +95,26 @@ const NATIVE_MODULE_ERROR = // Get the native module or use the browser implementation const usingBrowserMode = shouldUseBrowserMode(); const RNPurchases = usingBrowserMode ? browserNativeModuleRNPurchases : NativeModules.RNPurchases; + // Only create event emitter if native module is available to avoid crash on import -const eventEmitter = !usingBrowserMode && RNPurchases ? new NativeEventEmitter(RNPurchases) : null; +// Wrapped in try-catch to handle React Native 0.79+ where NativeEventEmitter +// constructor is stricter and can throw if the native module is not properly initialized +let eventEmitter: NativeEventEmitter | null = null; +if (!usingBrowserMode && RNPurchases) { + try { + eventEmitter = new NativeEventEmitter(RNPurchases); + } catch (error) { + // NativeEventEmitter construction failed (likely RN 0.79+ compatibility issue) + // Event listeners won't work, but the SDK will still function for basic operations + // tslint:disable-next-line:no-console + console.warn( + '[RevenueCat] Failed to create NativeEventEmitter. ' + + 'Event listeners (customer info updates, promo purchases) will not work. ' + + 'This may happen if the native module is not properly initialized. ' + + 'Error:', error + ); + } +} // Helper function to check if native module is available - provides better error message than "Cannot read property X of null" function throwIfNativeModuleNotAvailable(): void { From 55714df5ed6e55dfb8751e801d5a421f7da41e08 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Thu, 12 Feb 2026 12:29:11 -0600 Subject: [PATCH 2/8] Add required addListener/removeListeners methods for iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Native 0.79+ requires native modules to implement addListener and removeListeners methods for NativeEventEmitter to work properly. Android already had these methods, but iOS was missing them. This completes the fix for #1298 by addressing both sides: - JavaScript: Try-catch wrapper for graceful degradation (previous commit) - Native iOS: Required event emitter methods (this commit) - Native Android: Already implemented ✓ --- ios/RNPurchases.m | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ios/RNPurchases.m b/ios/RNPurchases.m index 52c957f78..445ca77b8 100644 --- a/ios/RNPurchases.m +++ b/ios/RNPurchases.m @@ -53,6 +53,17 @@ - (void)sendEventWithName:(NSString *)name body:(id)body { RCT_EXPORT_MODULE(); +// Required for RN 0.79+ NativeEventEmitter support +// Without these methods, NativeEventEmitter constructor will throw +// See: https://github.com/RevenueCat/react-native-purchases/issues/1298 +RCT_EXPORT_METHOD(addListener:(NSString *)eventName) { + // Keep: Required for RN built in Event Emitter Calls. +} + +RCT_EXPORT_METHOD(removeListeners:(NSInteger)count) { + // Keep: Required for RN built in Event Emitter Calls. +} + RCT_EXPORT_METHOD(setupPurchases:(NSString *)apiKey appUserID:(nullable NSString *)appUserID purchasesAreCompletedBy:(nullable NSString *)purchasesAreCompletedBy From 38cc4fd403dfbc97bdecac48f70cfc55e047a792 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Thu, 12 Feb 2026 12:59:57 -0600 Subject: [PATCH 3/8] Remove try-catch wrapper now that native methods are properly implemented The try-catch was defensive programming, but now that we've added the required addListener/removeListeners methods to both iOS and Android native modules, it's no longer needed. Event listeners are critical functionality (customer info updates, promo purchases, logging, analytics). Better to fail fast if there's a real problem than silently degrade. Added documentation comments explaining the RN 0.79+ requirements and linking to the GitHub issue and React Native breaking changes. --- src/purchases.ts | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/src/purchases.ts b/src/purchases.ts index e02c4a54d..912d0bad0 100644 --- a/src/purchases.ts +++ b/src/purchases.ts @@ -97,24 +97,12 @@ const usingBrowserMode = shouldUseBrowserMode(); const RNPurchases = usingBrowserMode ? browserNativeModuleRNPurchases : NativeModules.RNPurchases; // Only create event emitter if native module is available to avoid crash on import -// Wrapped in try-catch to handle React Native 0.79+ where NativeEventEmitter -// constructor is stricter and can throw if the native module is not properly initialized -let eventEmitter: NativeEventEmitter | null = null; -if (!usingBrowserMode && RNPurchases) { - try { - eventEmitter = new NativeEventEmitter(RNPurchases); - } catch (error) { - // NativeEventEmitter construction failed (likely RN 0.79+ compatibility issue) - // Event listeners won't work, but the SDK will still function for basic operations - // tslint:disable-next-line:no-console - console.warn( - '[RevenueCat] Failed to create NativeEventEmitter. ' + - 'Event listeners (customer info updates, promo purchases) will not work. ' + - 'This may happen if the native module is not properly initialized. ' + - 'Error:', error - ); - } -} +// +// React Native 0.79+ requires native modules to implement addListener() and removeListeners() +// methods for NativeEventEmitter to work. Both iOS and Android native modules now have these. +// See: https://github.com/RevenueCat/react-native-purchases/issues/1298 +// See: https://reactnative.dev/blog/2025/04/08/react-native-0.79 (Breaking Changes section) +const eventEmitter = !usingBrowserMode && RNPurchases ? new NativeEventEmitter(RNPurchases) : null; // Helper function to check if native module is available - provides better error message than "Cannot read property X of null" function throwIfNativeModuleNotAvailable(): void { From 214e534d6a17e8053cfb8602c36b8627a8c1a5d4 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Thu, 12 Feb 2026 13:12:31 -0600 Subject: [PATCH 4/8] Improve addListener/removeListeners implementation Changes: - Call [super] to preserve RCTEventEmitter's lifecycle management - Change NSInteger to double for removeListeners parameter (matches RN base class) - Update comments to note this has been required since RN 0.65, not 0.79 - Add link to React Native source code for reference This preserves the base class's listener counting and startObserving/ stopObserving lifecycle callbacks while maintaining full RN 0.79+ compatibility. Sources: - https://github.com/facebook/react-native/blob/main/packages/react-native/React/Modules/RCTEventEmitter.m - https://github.com/react-native-device-info/react-native-device-info/commit/3917f339207a5a2b05e3922f7489a0568dfde666 --- ios/RNPurchases.m | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/ios/RNPurchases.m b/ios/RNPurchases.m index 445ca77b8..6cb88f791 100644 --- a/ios/RNPurchases.m +++ b/ios/RNPurchases.m @@ -53,15 +53,16 @@ - (void)sendEventWithName:(NSString *)name body:(id)body { RCT_EXPORT_MODULE(); -// Required for RN 0.79+ NativeEventEmitter support -// Without these methods, NativeEventEmitter constructor will throw +// Required for RN 0.65+ NativeEventEmitter support +// Without these methods, NativeEventEmitter constructor will throw in RN 0.79+ // See: https://github.com/RevenueCat/react-native-purchases/issues/1298 +// See: https://github.com/facebook/react-native/blob/main/packages/react-native/React/Modules/RCTEventEmitter.m RCT_EXPORT_METHOD(addListener:(NSString *)eventName) { - // Keep: Required for RN built in Event Emitter Calls. + [super addListener:eventName]; } -RCT_EXPORT_METHOD(removeListeners:(NSInteger)count) { - // Keep: Required for RN built in Event Emitter Calls. +RCT_EXPORT_METHOD(removeListeners:(double)count) { + [super removeListeners:count]; } RCT_EXPORT_METHOD(setupPurchases:(NSString *)apiKey From 0cec1fc8b7482758687fe200059d489059287028 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Thu, 12 Feb 2026 13:17:00 -0600 Subject: [PATCH 5/8] Add detailed documentation with exact GitHub source URLs Added inline documentation explaining what [super addListener] and [super removeListeners] actually do: - Validates eventName against supportedEvents (debug only) - Tracks _listenerCount - Calls startObserving/stopObserving lifecycle methods Includes direct GitHub blob URL with line numbers to React Native's RCTEventEmitter.m implementation (lines 101-125). This helps future developers understand exactly what's happening when these methods are called. --- ios/RNPurchases.m | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ios/RNPurchases.m b/ios/RNPurchases.m index 6cb88f791..3f166c336 100644 --- a/ios/RNPurchases.m +++ b/ios/RNPurchases.m @@ -55,8 +55,18 @@ - (void)sendEventWithName:(NSString *)name body:(id)body { // Required for RN 0.65+ NativeEventEmitter support // Without these methods, NativeEventEmitter constructor will throw in RN 0.79+ +// +// [super addListener] does the following: +// 1. Validates eventName is in supportedEvents (debug builds only) +// 2. Increments _listenerCount +// 3. Calls startObserving when first listener is added (_listenerCount == 1) +// +// [super removeListeners] does the following: +// 1. Decrements _listenerCount by count +// 2. Calls stopObserving when last listener is removed (_listenerCount == 0) +// // See: https://github.com/RevenueCat/react-native-purchases/issues/1298 -// See: https://github.com/facebook/react-native/blob/main/packages/react-native/React/Modules/RCTEventEmitter.m +// See: https://github.com/facebook/react-native/blob/main/packages/react-native/React/Modules/RCTEventEmitter.m#L101-L125 RCT_EXPORT_METHOD(addListener:(NSString *)eventName) { [super addListener:eventName]; } From 38c20c28306bf6aed330e728281fc57140eb8c28 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Thu, 12 Feb 2026 13:18:41 -0600 Subject: [PATCH 6/8] Improve comments to explain WHY [super] is needed, not just what it does Changed focus from describing what [super addListener] does to explaining WHY we call it: - We must export these methods for RN 0.79+ compatibility - We call [super] to preserve RCTEventEmitter's listener counting and lifecycle - Without [super], we'd lose startObserving/stopObserving functionality This makes it clearer for future developers why we can't just have empty stubs. --- ios/RNPurchases.m | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/ios/RNPurchases.m b/ios/RNPurchases.m index 3f166c336..8b9826d06 100644 --- a/ios/RNPurchases.m +++ b/ios/RNPurchases.m @@ -54,16 +54,15 @@ - (void)sendEventWithName:(NSString *)name body:(id)body { RCT_EXPORT_MODULE(); // Required for RN 0.65+ NativeEventEmitter support -// Without these methods, NativeEventEmitter constructor will throw in RN 0.79+ +// Without these methods exported, NativeEventEmitter constructor will throw in RN 0.79+ // -// [super addListener] does the following: -// 1. Validates eventName is in supportedEvents (debug builds only) -// 2. Increments _listenerCount -// 3. Calls startObserving when first listener is added (_listenerCount == 1) +// Why we call [super]: +// RCTEventEmitter's base implementation tracks listener count and calls lifecycle methods +// (startObserving/stopObserving). If we had empty implementations, we'd lose this functionality. +// By calling [super], we preserve the base class's listener counting and lifecycle management. // -// [super removeListeners] does the following: -// 1. Decrements _listenerCount by count -// 2. Calls stopObserving when last listener is removed (_listenerCount == 0) +// What [super addListener] does: validates eventName, increments _listenerCount, calls startObserving when first listener added +// What [super removeListeners] does: decrements _listenerCount, calls stopObserving when last listener removed // // See: https://github.com/RevenueCat/react-native-purchases/issues/1298 // See: https://github.com/facebook/react-native/blob/main/packages/react-native/React/Modules/RCTEventEmitter.m#L101-L125 From 4f2350b6b58c897a62c957649168f981b5267f3e Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Thu, 12 Feb 2026 13:20:18 -0600 Subject: [PATCH 7/8] Clarify WHY we must re-export methods from parent class The key insight: RCT_EXPORT_METHOD declarations are NOT inherited in React Native. Even though RCTEventEmitter (our parent) has addListener and removeListeners implemented, the React Native bridge only sees methods that are explicitly exported in RNPurchases itself. We must re-export them here to make them visible to JavaScript, then call [super] to delegate to the parent's implementation. This explains the 'why' - not implementation details. --- ios/RNPurchases.m | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/ios/RNPurchases.m b/ios/RNPurchases.m index 8b9826d06..f37b5e808 100644 --- a/ios/RNPurchases.m +++ b/ios/RNPurchases.m @@ -54,15 +54,13 @@ - (void)sendEventWithName:(NSString *)name body:(id)body { RCT_EXPORT_MODULE(); // Required for RN 0.65+ NativeEventEmitter support -// Without these methods exported, NativeEventEmitter constructor will throw in RN 0.79+ +// Without these methods exported HERE, NativeEventEmitter constructor will throw in RN 0.79+ // -// Why we call [super]: -// RCTEventEmitter's base implementation tracks listener count and calls lifecycle methods -// (startObserving/stopObserving). If we had empty implementations, we'd lose this functionality. -// By calling [super], we preserve the base class's listener counting and lifecycle management. -// -// What [super addListener] does: validates eventName, increments _listenerCount, calls startObserving when first listener added -// What [super removeListeners] does: decrements _listenerCount, calls stopObserving when last listener removed +// WHY export them here when RCTEventEmitter already has them? +// React Native's bridge only exposes methods that are EXPLICITLY exported in each class. +// Even though our parent class (RCTEventEmitter) has these methods, they are NOT automatically +// visible to JavaScript from RNPurchases. We must re-export them here to make them callable +// from JS, then delegate to [super] to use the parent's implementation. // // See: https://github.com/RevenueCat/react-native-purchases/issues/1298 // See: https://github.com/facebook/react-native/blob/main/packages/react-native/React/Modules/RCTEventEmitter.m#L101-L125 From e1891e2ad415c1e65790f6fec0f7c4605449ab0f Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Thu, 12 Feb 2026 13:22:06 -0600 Subject: [PATCH 8/8] Clarify: NativeEventEmitter (JS) vs RCTEventEmitter (native) Fixed confusion in documentation: - NativeEventEmitter = JavaScript class that wraps native modules - RCTEventEmitter = Objective-C parent class of RNPurchases The JavaScript NativeEventEmitter constructor checks if the native module has addListener/removeListeners methods. We inherit from RCTEventEmitter (native), but must re-export its methods because RCT_EXPORT_METHOD doesn't inherit automatically. --- ios/RNPurchases.m | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/ios/RNPurchases.m b/ios/RNPurchases.m index f37b5e808..d52113c83 100644 --- a/ios/RNPurchases.m +++ b/ios/RNPurchases.m @@ -53,14 +53,16 @@ - (void)sendEventWithName:(NSString *)name body:(id)body { RCT_EXPORT_MODULE(); -// Required for RN 0.65+ NativeEventEmitter support -// Without these methods exported HERE, NativeEventEmitter constructor will throw in RN 0.79+ +// Required for RN 0.65+ NativeEventEmitter (JavaScript class) support // -// WHY export them here when RCTEventEmitter already has them? -// React Native's bridge only exposes methods that are EXPLICITLY exported in each class. -// Even though our parent class (RCTEventEmitter) has these methods, they are NOT automatically -// visible to JavaScript from RNPurchases. We must re-export them here to make them callable -// from JS, then delegate to [super] to use the parent's implementation. +// In JavaScript: new NativeEventEmitter(RNPurchases) +// NativeEventEmitter checks if the native module has addListener/removeListeners methods. +// Without these exported methods, construction throws in RN 0.79+. +// +// WHY export them here when our parent class RCTEventEmitter already has them? +// React Native's bridge only exposes methods that are EXPLICITLY exported with RCT_EXPORT_METHOD. +// Parent class methods are NOT automatically visible to JavaScript. We must re-export them here +// to make them callable from JS, then call [super] to use the parent's implementation. // // See: https://github.com/RevenueCat/react-native-purchases/issues/1298 // See: https://github.com/facebook/react-native/blob/main/packages/react-native/React/Modules/RCTEventEmitter.m#L101-L125