From 7d6f19394d14942a084f8174eabb956312703b2f Mon Sep 17 00:00:00 2001 From: Terry <186405119+tlq5l@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:10:53 +0700 Subject: [PATCH 1/4] fix(ios): guard native-to-JS messages until the TurboModule event emitter is ready emitMessageFromNative: only checked the module singleton. The event emitter callback is set when getTurboModule: builds the JSI spec, so a message posted before JS first requires the module called an empty std::function and threw std::bad_function_call. Drop the message with a log line instead. Fixes #420 Agent: claude --- .changeset/ios-emit-before-module-ready.md | 5 +++++ .../ios/ReactNativeBrownfieldModule.mm | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 .changeset/ios-emit-before-module-ready.md diff --git a/.changeset/ios-emit-before-module-ready.md b/.changeset/ios-emit-before-module-ready.md new file mode 100644 index 000000000..a166ded2b --- /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 before JS has loaded the Brownfield module. The message is now dropped with a log line instead. diff --git a/packages/react-native-brownfield/ios/ReactNativeBrownfieldModule.mm b/packages/react-native-brownfield/ios/ReactNativeBrownfieldModule.mm index ee44acc9b..bebeaadcf 100644 --- a/packages/react-native-brownfield/ios/ReactNativeBrownfieldModule.mm +++ b/packages/react-native-brownfield/ios/ReactNativeBrownfieldModule.mm @@ -41,11 +41,20 @@ - (void)handleNativeToJSMessage:(NSNotification *)notification { } + (void)emitMessageFromNative:(NSString *)message { - if (_sharedInstance) { - [_sharedInstance emitOnBrownfieldMessage:@{ @"text": message }]; - } else { + if (!_sharedInstance) { NSLog(@"ReactNativeBrownfieldModule is not initialized, dropping message"); + return; } + + // The event emitter callback is only set once JS has first required the + // module and getTurboModule: has built its JSI spec. Calling it empty throws + // std::bad_function_call. + if (!_sharedInstance->_eventEmitterCallback) { + NSLog(@"ReactNativeBrownfieldModule is not ready for JS yet, dropping message"); + return; + } + + [_sharedInstance emitOnBrownfieldMessage:@{ @"text": message }]; } RCT_EXPORT_METHOD(setPopGestureRecognizerEnabled:(BOOL)enabled) { From b28622fd10b59c37da41d886658f5ad897229263 Mon Sep 17 00:00:00 2001 From: Terry <186405119+tlq5l@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:45:04 +0700 Subject: [PATCH 2/4] docs(ios): source the emitter-callback comment and document dropped early messages The code comment now links the codegen lines and the std::function call operator page for the claims it makes. The Swift, Objective-C and doc comment for postMessage say a message sent before JS loads the Brownfield module is dropped with a log line. Agent: claude --- .../react-native-brownfield/objective-c.mdx | 2 +- .../docs/api-reference/react-native-brownfield/swift.mdx | 2 +- .../ios/ReactNativeBrownfield.swift | 1 + .../ios/ReactNativeBrownfieldModule.mm | 9 +++++++-- 4 files changed, 10 insertions(+), 4 deletions(-) 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 f663f5aaa..2c7be886b 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. The message is delivered as a `brownfieldMessage` DeviceEventEmitter event. If JS has not loaded the Brownfield module yet, the message is dropped and a log line is written. | 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 a8dd87ef1..a92a0289f 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. The message is delivered as a `brownfieldMessage` DeviceEventEmitter event and can be received using `ReactNativeBrownfield.onMessage()` on the JS side. If JS has not loaded the Brownfield module yet, the message is dropped and a log line is written. | Param | Required | Type | Description | | --------- | -------- | -------- | ----------------------------------- | diff --git a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift index e8384e4ea..b1d5e0810 100644 --- a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift +++ b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift @@ -308,6 +308,7 @@ internal import Expo /** * Send a serialized JSON message to the React Native JS application. * The message is delivered as a `brownfieldMessage` DeviceEventEmitter event. + * If JS has not loaded the Brownfield module yet, the message is dropped and a log line is written. * * @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 bebeaadcf..157b35778 100644 --- a/packages/react-native-brownfield/ios/ReactNativeBrownfieldModule.mm +++ b/packages/react-native-brownfield/ios/ReactNativeBrownfieldModule.mm @@ -47,8 +47,13 @@ + (void)emitMessageFromNative:(NSString *)message { } // The event emitter callback is only set once JS has first required the - // module and getTurboModule: has built its JSI spec. Calling it empty throws - // std::bad_function_call. + // module and getTurboModule: has built its JSI spec. The codegen base class + // declares it: + // https://github.com/facebook/react-native/blob/4bc2473f5d0233ea5384c1ef24f6a55615de2220/packages/react-native-codegen/src/generators/modules/GenerateModuleObjCpp/index.js#L44 + // and the generated constructor sets it: + // https://github.com/facebook/react-native/blob/4bc2473f5d0233ea5384c1ef24f6a55615de2220/packages/react-native-codegen/src/generators/modules/GenerateModuleObjCpp/source/serializeModule.js#L88 + // 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; From 10d94a4a208481959cc59ffe55596ca2824cf1b6 Mon Sep 17 00:00:00 2001 From: Terry <186405119+tlq5l@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:13:26 +0700 Subject: [PATCH 3/4] fix(ios): serialize the TurboModule event emitter callback React Native installs the callback on the JS require path, while postMessage delivers on the main queue. Read, install and clear the callback under one lock, clear it on invalidate, and document that the drop log is written only when the module exists but its emitter is not ready. Agent: claude --- .changeset/ios-emit-before-module-ready.md | 2 +- .../react-native-brownfield/objective-c.mdx | 2 +- .../react-native-brownfield/swift.mdx | 2 +- .../ios/ReactNativeBrownfield.swift | 3 +- .../ios/ReactNativeBrownfieldModule.mm | 68 +++++++++++++------ 5 files changed, 51 insertions(+), 26 deletions(-) diff --git a/.changeset/ios-emit-before-module-ready.md b/.changeset/ios-emit-before-module-ready.md index a166ded2b..3d7761737 100644 --- a/.changeset/ios-emit-before-module-ready.md +++ b/.changeset/ios-emit-before-module-ready.md @@ -2,4 +2,4 @@ '@callstack/react-native-brownfield': patch --- -Fix an iOS crash (`std::bad_function_call`) when native code posts a message to JS before JS has loaded the Brownfield module. The message is now dropped with a log line instead. +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/objective-c.mdx b/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx index 2c7be886b..fbf6f799d 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. If JS has not loaded the Brownfield module yet, the message is dropped and a log line is written. +Send a JSON string to the React Native JS layer. The message is delivered as a `brownfieldMessage` DeviceEventEmitter event. 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 a92a0289f..de84f2dd3 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. If JS has not loaded the Brownfield module yet, the message is dropped and a log line is written. +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. 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 b1d5e0810..04bc8171c 100644 --- a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift +++ b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift @@ -308,7 +308,8 @@ internal import Expo /** * Send a serialized JSON message to the React Native JS application. * The message is delivered as a `brownfieldMessage` DeviceEventEmitter event. - * If JS has not loaded the Brownfield module yet, the message is dropped and a log line is written. + * 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 157b35778..7a369e437 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,25 +54,29 @@ - (void)handleNativeToJSMessage:(NSNotification *)notification { } + (void)emitMessageFromNative:(NSString *)message { - if (!_sharedInstance) { - NSLog(@"ReactNativeBrownfieldModule is not initialized, dropping message"); - return; + // 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 }]; } - - // The event emitter callback is only set once JS has first required the - // module and getTurboModule: has built its JSI spec. The codegen base class - // declares it: - // https://github.com/facebook/react-native/blob/4bc2473f5d0233ea5384c1ef24f6a55615de2220/packages/react-native-codegen/src/generators/modules/GenerateModuleObjCpp/index.js#L44 - // and the generated constructor sets it: - // https://github.com/facebook/react-native/blob/4bc2473f5d0233ea5384c1ef24f6a55615de2220/packages/react-native-codegen/src/generators/modules/GenerateModuleObjCpp/source/serializeModule.js#L88 - // 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 }]; } RCT_EXPORT_METHOD(setPopGestureRecognizerEnabled:(BOOL)enabled) { @@ -79,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 From f5908f969ad591ffa24a1f37e4fad5a4044d11e2 Mon Sep 17 00:00:00 2001 From: Terry <186405119+tlq5l@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:56:18 +0700 Subject: [PATCH 4/4] docs: point postMessage docs at onMessage instead of a DeviceEventEmitter event Messages can be received using ReactNativeBrownfield.onMessage() (packages/react-native-brownfield/src/index.ts:94-106). Agent: claude --- docs/docs/docs/api-reference/react-native-brownfield/java.mdx | 2 +- docs/docs/docs/api-reference/react-native-brownfield/kotlin.mdx | 2 +- .../docs/api-reference/react-native-brownfield/objective-c.mdx | 2 +- docs/docs/docs/api-reference/react-native-brownfield/swift.mdx | 2 +- .../react-native-brownfield/ios/ReactNativeBrownfield.swift | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) 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 cb9b54164..e152769fa 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 ec1ca99ee..25535bc76 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 fbf6f799d..409f712d3 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. 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. +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 de84f2dd3..080d83d71 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. 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. +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 04bc8171c..10b4d07f9 100644 --- a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift +++ b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift @@ -307,7 +307,7 @@ 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. *