diff --git a/.changeset/ios-emit-before-module-ready.md b/.changeset/ios-emit-before-module-ready.md new file mode 100644 index 00000000..3d776173 --- /dev/null +++ b/.changeset/ios-emit-before-module-ready.md @@ -0,0 +1,5 @@ +--- +'@callstack/react-native-brownfield': patch +--- + +Fix an iOS crash (`std::bad_function_call`) when native code posts a message to JS while the Brownfield module's JS emitter is still being set up. If the Brownfield module exists but its JS emitter is not ready when delivery happens, the message is dropped and a log line is written. If the module does not exist yet, the message is dropped without a log line. diff --git a/docs/docs/docs/api-reference/react-native-brownfield/java.mdx b/docs/docs/docs/api-reference/react-native-brownfield/java.mdx index cb9b5416..e152769f 100644 --- a/docs/docs/docs/api-reference/react-native-brownfield/java.mdx +++ b/docs/docs/docs/api-reference/react-native-brownfield/java.mdx @@ -206,7 +206,7 @@ container.addView(reactView); ##### `postMessage` -Send a JSON string to the React Native JS layer. The message is delivered as a `brownfieldMessage` DeviceEventEmitter event and can be received using `ReactNativeBrownfield.onMessage()` on the JS side. +Send a JSON string to the React Native JS layer. Messages can be received using `ReactNativeBrownfield.onMessage()`. | Param | Required | Type | Description | | ------- | -------- | -------- | ----------------------------------- | diff --git a/docs/docs/docs/api-reference/react-native-brownfield/kotlin.mdx b/docs/docs/docs/api-reference/react-native-brownfield/kotlin.mdx index ec1ca99e..25535bc7 100644 --- a/docs/docs/docs/api-reference/react-native-brownfield/kotlin.mdx +++ b/docs/docs/docs/api-reference/react-native-brownfield/kotlin.mdx @@ -203,7 +203,7 @@ AndroidView( ##### `postMessage` -Send a JSON string to the React Native JS layer. The message is delivered as a `brownfieldMessage` DeviceEventEmitter event and can be received using `ReactNativeBrownfield.onMessage()` on the JS side. +Send a JSON string to the React Native JS layer. Messages can be received using `ReactNativeBrownfield.onMessage()`. | Param | Required | Type | Description | | ------- | -------- | -------- | ----------------------------------- | diff --git a/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx b/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx index f663f5aa..409f712d 100644 --- a/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx +++ b/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx @@ -129,7 +129,7 @@ Returns: `BOOL` – return value is passed through from the runtime. ##### `postMessage` -Send a JSON string to the React Native JS layer. The message is delivered as a `brownfieldMessage` DeviceEventEmitter event. +Send a JSON string to the React Native JS layer. Messages can be received using `ReactNativeBrownfield.onMessage()`. If the Brownfield module exists but its JS emitter is not ready when delivery happens, the message is dropped and a log line is written. If the module does not exist yet, the message is dropped without a log line. | Param | Required | Type | Description | | --------- | -------- | ---------- | ----------------------------------- | diff --git a/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx b/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx index a8dd87ef..080d83d7 100644 --- a/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx +++ b/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx @@ -119,7 +119,7 @@ let view = ReactNativeBrownfield.shared.view( ##### `postMessage` -Send a JSON string to the React Native JS layer. The message is delivered as a `brownfieldMessage` DeviceEventEmitter event and can be received using `ReactNativeBrownfield.onMessage()` on the JS side. +Send a JSON string to the React Native JS layer. Messages can be received using `ReactNativeBrownfield.onMessage()`. If the Brownfield module exists but its JS emitter is not ready when delivery happens, the message is dropped and a log line is written. If the module does not exist yet, the message is dropped without a log line. | Param | Required | Type | Description | | --------- | -------- | -------- | ----------------------------------- | diff --git a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift index e8384e4e..10b4d07f 100644 --- a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift +++ b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift @@ -307,7 +307,9 @@ internal import Expo /** * Send a serialized JSON message to the React Native JS application. - * The message is delivered as a `brownfieldMessage` DeviceEventEmitter event. + * Messages can be received using `ReactNativeBrownfield.onMessage()`. + * If the Brownfield module exists but its JS emitter is not ready when delivery happens, the message is dropped and a log line is written. + * If the module does not exist yet, the message is dropped without a log line. * * @param message - The serialized JSON message to send to the React Native JS application. * @example diff --git a/packages/react-native-brownfield/ios/ReactNativeBrownfieldModule.mm b/packages/react-native-brownfield/ios/ReactNativeBrownfieldModule.mm index ee44acc9..7a369e43 100644 --- a/packages/react-native-brownfield/ios/ReactNativeBrownfieldModule.mm +++ b/packages/react-native-brownfield/ios/ReactNativeBrownfieldModule.mm @@ -15,7 +15,9 @@ @implementation ReactNativeBrownfieldModule - (instancetype)init { self = [super init]; if (self) { - _sharedInstance = self; + @synchronized (ReactNativeBrownfieldModule.class) { + _sharedInstance = self; + } [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNativeToJSMessage:) @@ -28,8 +30,19 @@ - (instancetype)init { - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; - if (_sharedInstance == self) { - _sharedInstance = nil; + @synchronized (ReactNativeBrownfieldModule.class) { + if (_sharedInstance == self) { + _sharedInstance = nil; + } + } +} + +// React Native calls invalidate on modules during teardown (_invalidateModules): +// https://github.com/facebook/react-native/blob/4bc2473f5d0233ea5384c1ef24f6a55615de2220/packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModuleManager.mm#L944-L985 +// Clearing the callback sends later messages down the not-ready drop path. +- (void)invalidate { + @synchronized (ReactNativeBrownfieldModule.class) { + _eventEmitterCallback = nullptr; } } @@ -41,10 +54,28 @@ - (void)handleNativeToJSMessage:(NSNotification *)notification { } + (void)emitMessageFromNative:(NSString *)message { - if (_sharedInstance) { + // The singleton and the callback are read and written under one lock. + // postMessage delivers on the main queue, while getTurboModule: (below) installs the + // callback when JS first requires the module. React Native calls getTurboModule: from + // https://github.com/facebook/react-native/blob/4bc2473f5d0233ea5384c1ef24f6a55615de2220/packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModuleManager.mm#L336-L345 + // and the JS require path is + // https://github.com/facebook/react-native/blob/4bc2473f5d0233ea5384c1ef24f6a55615de2220/packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModuleManager.mm#L845-L860. + @synchronized (ReactNativeBrownfieldModule.class) { + if (!_sharedInstance) { + NSLog(@"ReactNativeBrownfieldModule is not initialized, dropping message"); + return; + } + + // The codegen base class declares the callback as a @protected ivar: + // https://github.com/facebook/react-native/blob/4bc2473f5d0233ea5384c1ef24f6a55615de2220/packages/react-native-codegen/src/generators/modules/GenerateModuleObjCpp/index.js#L44 + // Calling an empty std::function throws std::bad_function_call: + // https://en.cppreference.com/w/cpp/utility/functional/function/operator%28%29.html + if (!_sharedInstance->_eventEmitterCallback) { + NSLog(@"ReactNativeBrownfieldModule is not ready for JS yet, dropping message"); + return; + } + [_sharedInstance emitOnBrownfieldMessage:@{ @"text": message }]; - } else { - NSLog(@"ReactNativeBrownfieldModule is not initialized, dropping message"); } } @@ -65,7 +96,14 @@ + (void)emitMessageFromNative:(NSString *)message { RCT_EXPORT_METHOD(removeListeners:(double)count) {} - (std::shared_ptr)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params { - return std::make_shared(params); + // Building the spec installs _eventEmitterCallback. The spec constructor calls setEventEmitterCallback + // (https://github.com/facebook/react-native/blob/4bc2473f5d0233ea5384c1ef24f6a55615de2220/packages/react-native-codegen/src/generators/modules/GenerateModuleObjCpp/source/serializeModule.js#L88), + // ObjCTurboModule forwards it (https://github.com/facebook/react-native/blob/4bc2473f5d0233ea5384c1ef24f6a55615de2220/packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm#L924-L931), + // and the generated setter assigns the ivar (https://github.com/facebook/react-native/blob/4bc2473f5d0233ea5384c1ef24f6a55615de2220/packages/react-native-codegen/src/generators/modules/GenerateModuleObjCpp/source/serializeModule.js#L44). + // Hold the same lock as emitMessageFromNative: so the write and the read do not race. + @synchronized (ReactNativeBrownfieldModule.class) { + return std::make_shared(params); + } } @end