react-native
react-native copied to clipboard
feat(tm): Reject Promise if Turbo Module method throws an Error
Summary:
This PR builds upon the previous work done in https://github.com/facebook/react-native/pull/36925, which introduced native stack traces to the JSError for synchronous functions.
The current modifications concentrate on functions that return Promises. Prior to this PR, errors within Promise-returning functions would be thrown at the platform layer crashing the app without a link to the JS stack.
After the implementation of this PR, errors thrown within Promise-returning functions are now captured and transformed into rejected Promises. These rejected Promises contain a JS Error object that contains both the JS stack trace and the cause, along with the platform stack trace.
Additionally, this PR ensures that rejections from native functions are now linked to the JS stack trace, providing a more comprehensive view of the rejection flow.
Changelog:
[GENERAL][ADDED] - Turbo Modules Promise-returning functions reject with JS and platform stack traces information
Test Plan:
| iOS | Android |
|---|---|
Example of intentionally rejected promise on Android:
{
"name": "Error",
"message": "Exception in HostFunction: intentional promise rejection",
"stack": "[native code]\ntryCallTwo@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:25844:9\ndoResolve@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:25975:25\nPromise@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:25863:14\n[native code]\nrejectPromise@http://10.0.2.2:8081/js/examples/TurboModule/SampleTurboModuleExample.bundle?platform=android&lazy=true&app=com.facebook.react.uiapp&modulesOnly=true&dev=true&minify=false&runModule=true&shallow=true:42:70\nonPress@http://10.0.2.2:8081/js/examples/TurboModule/SampleTurboModuleExample.bundle?platform=android&lazy=true&app=com.facebook.react.uiapp&modulesOnly=true&dev=true&minify=false&runModule=true&shallow=true:242:71\n_performTransitionSideEffects@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:51896:22\n_receiveSignal@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:51852:45\nonResponderRelease@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:51715:34\ninvokeGuardedCallbackProd@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:2962:21\ninvokeGuardedCallback@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:3048:42\ninvokeGuardedCallbackAndCatchFirstError@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:3051:36\nexecuteDispatch@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:3115:48\nexecuteDispatchesInOrder@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:3132:26\nexecuteDispatchesAndRelease@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:4856:35\nforEach@[native code]\nforEachAccumulated@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:3574:22\nrunEventsInBatch@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:4874:27\nrunExtractedPluginEventsInBatch@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:4896:25\nhttp://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:4914:42\nbatchedUpdates$1@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:14750:20\nbatchedUpdates@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:4845:36\ndispatchEvent@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:4907:23",
"cause": {
"nativeStackAndroid": [
{
"lineNumber": 173,
"file": "SampleTurboModule.java",
"methodName": "getValueWithPromise",
"class": "com.facebook.fbreact.specs.SampleTurboModule"
},
{
"lineNumber": -2,
"file": "NativeRunnable.java",
"methodName": "run",
"class": "com.facebook.jni.NativeRunnable"
},
{
"lineNumber": 942,
"file": "Handler.java",
"methodName": "handleCallback",
"class": "android.os.Handler"
},
{
"lineNumber": 99,
"file": "Handler.java",
"methodName": "dispatchMessage",
"class": "android.os.Handler"
},
{
"lineNumber": 27,
"file": "MessageQueueThreadHandler.java",
"methodName": "dispatchMessage",
"class": "com.facebook.react.bridge.queue.MessageQueueThreadHandler"
},
{
"lineNumber": 201,
"file": "Looper.java",
"methodName": "loopOnce",
"class": "android.os.Looper"
},
{
"lineNumber": 288,
"file": "Looper.java",
"methodName": "loop",
"class": "android.os.Looper"
},
{
"lineNumber": 228,
"file": "MessageQueueThreadImpl.java",
"methodName": "run",
"class": "com.facebook.react.bridge.queue.MessageQueueThreadImpl$4"
},
{
"lineNumber": 1012,
"file": "Thread.java",
"methodName": "run",
"class": "java.lang.Thread"
}
],
"userInfo": null,
"message": "intentional promise rejection",
"code": "code 1"
}
}
Example of intentionally rejected promise on iOS:
{
"name": "Error",
"message": "Exception in HostFunction: intentional promise rejection",
"stack": "@[native code]\nrejectPromise@http://localhost:8081/js/examples/TurboModule/SampleTurboModuleExample.bundle:42:70\nonPress@http://localhost:8081/js/examples/TurboModule/SampleTurboModuleExample.bundle:242:71\n_performTransitionSideEffects@http://localhost:8081/js/RNTesterApp.ios.bundle:52614:22\n_receiveSignal@http://localhost:8081/js/RNTesterApp.ios.bundle:52570:45\nonResponderRelease@http://localhost:8081/js/RNTesterApp.ios.bundle:52433:34\ninvokeGuardedCallbackProd@http://localhost:8081/js/RNTesterApp.ios.bundle:3068:21\ninvokeGuardedCallback@http://localhost:8081/js/RNTesterApp.ios.bundle:3154:42\ninvokeGuardedCallbackAndCatchFirstError@http://localhost:8081/js/RNTesterApp.ios.bundle:3157:36\nexecuteDispatch@http://localhost:8081/js/RNTesterApp.ios.bundle:3221:48\nexecuteDispatchesInOrder@http://localhost:8081/js/RNTesterApp.ios.bundle:3238:26\nexecuteDispatchesAndRelease@http://localhost:8081/js/RNTesterApp.ios.bundle:4962:35\nforEach@[native code]\nforEachAccumulated@http://localhost:8081/js/RNTesterApp.ios.bundle:3680:22\nrunEventsInBatch@http://localhost:8081/js/RNTesterApp.ios.bundle:4980:27\nrunExtractedPluginEventsInBatch@http://localhost:8081/js/RNTesterApp.ios.bundle:5002:25\n@http://localhost:8081/js/RNTesterApp.ios.bundle:5020:42\nbatchedUpdates$1@http://localhost:8081/js/RNTesterApp.ios.bundle:14856:20\nbatchedUpdates@http://localhost:8081/js/RNTesterApp.ios.bundle:4951:36\ndispatchEvent@http://localhost:8081/js/RNTesterApp.ios.bundle:5013:23",
"cause": {
"code": "code_1",
"stackSymbols": [
"0 RNTester 0x00000001031f9958 RCTJSErrorFromCodeMessageAndNSError + 112",
"1 RNTester 0x00000001034cecc8 ___ZZN8facebook5react15ObjCTurboModule13createPromiseERNS_3jsi7RuntimeENSt3__112basic_stringIcNS5_11char_traitsIcEENS5_9allocatorIcEEEEU13block_pointerFvU13block_pointerFvP11objc_objectEU13block_pointerFvP8NSStringSH_P7NSErrorEU13block_pointerFvP11NSExceptionEEENK3$_0clES4_RKNS2_5ValueEPSU_m_block_invoke.66 + 96",
"2 RNTester 0x000000010374e774 -[RCTSampleTurboModule getValueWithPromise:resolve:reject:] + 232",
"3 CoreFoundation 0x000000018043d6c0 __invoking___ + 144",
"4 CoreFoundation 0x000000018043aa44 -[NSInvocation invoke] + 276",
"5 CoreFoundation 0x000000018043acdc -[NSInvocation invokeWithTarget:] + 60",
"6 RNTester 0x00000001034c4de4 ___ZN8facebook5react15ObjCTurboModule23performMethodInvocationERNS_3jsi7RuntimeEbPKcP12NSInvocationP14NSMutableArrayU13block_pointerFvP11NSExceptionE_block_invoke + 240",
"7 RNTester 0x00000001034d5120 _ZZN8facebook5react15ObjCTurboModule23performMethodInvocationERNS_3jsi7RuntimeEbPKcP12NSInvocationP14NSMutableArrayU13block_pointerFvP11NSExceptionEENK3$_1clEv + 32",
"8 RNTester 0x00000001034d50f4 _ZNSt3__18__invokeB6v15006IRZN8facebook5react15ObjCTurboModule23performMethodInvocationERNS1_3jsi7RuntimeEbPKcP12NSInvocationP14NSMutableArrayU13block_pointerFvP11NSExceptionEE3$_1JEEEDTclclsr3stdE7declvalIT_EEspclsr3stdE7declvalIT0_EEEEOSJ_DpOSK_ + 24",
"9 RNTester 0x00000001034d50ac _ZNSt3__128__invoke_void_return_wrapperIvLb1EE6__callIJRZN8facebook5react15ObjCTurboModule23performMethodInvocationERNS3_3jsi7RuntimeEbPKcP12NSInvocationP14NSMutableArrayU13block_pointerFvP11NSExceptionEE3$_1EEEvDpOT_ + 24",
"10 RNTester 0x00000001034d5088 _ZNSt3__110__function12__alloc_funcIZN8facebook5react15ObjCTurboModule23performMethodInvocationERNS2_3jsi7RuntimeEbPKcP12NSInvocationP14NSMutableArrayU13block_pointerFvP11NSExceptionEE3$_1NS_9allocatorISI_EEFvvEEclB6v15006Ev + 28",
"11 RNTester 0x00000001034d3ce0 _ZNSt3__110__function6__funcIZN8facebook5react15ObjCTurboModule23performMethodInvocationERNS2_3jsi7RuntimeEbPKcP12NSInvocationP14NSMutableArrayU13block_pointerFvP11NSExceptionEE3$_1NS_9allocatorISI_EEFvvEEclEv + 28",
"12 RNTester 0x000000010308d65c _ZNKSt3__110__function12__value_funcIFvvEEclB6v15006Ev + 68",
"13 RNTester 0x000000010308d60c _ZNKSt3__18functionIFvvEEclEv + 24",
"14 RNTester 0x00000001034dbd24 ___ZN12_GLOBAL__N_128MethodQueueNativeCallInvoker11invokeAsyncEONSt3__18functionIFvvEEE_block_invoke + 44",
"15 libdispatch.dylib 0x0000000106820528 _dispatch_call_block_and_release + 24",
"16 libdispatch.dylib 0x0000000106821d50 _dispatch_client_callout + 16",
"17 libdispatch.dylib 0x0000000106832808 _dispatch_main_queue_drain + 1316",
"18 libdispatch.dylib 0x00000001068322d4 _dispatch_main_queue_callback_4CF + 40",
"19 CoreFoundation 0x000000018039a784 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 12",
"20 CoreFoundation 0x0000000180394de4 __CFRunLoopRun + 1912",
"21 CoreFoundation 0x0000000180394254 CFRunLoopRunSpecific + 584",
"22 GraphicsServices 0x0000000188eb7c9c GSEventRunModal + 160",
"23 UIKitCore 0x0000000112b32ff0 -[UIApplication _run] + 868",
"24 UIKitCore 0x0000000112b36f3c UIApplicationMain + 124",
"25 RNTester 0x0000000102e7029c main + 96",
"26 dyld 0x0000000106721514 start_sim + 20",
"27 ??? 0x00000001068fde50 0x0 + 4405059152",
"28 ??? 0x7539800000000000 0x0 + 8446923313598431232"
],
"message": "intentional promise rejection",
"domain": "RCTSampleTurboModule",
"userInfo": {}
}
}
How I logged out the Errors:
console.log('Error in JS:', JSON.stringify({
name: e.name,
message: e.message,
stack: e.stack,
...e,
}, null, 2));
| Platform | Engine | Arch | Size (bytes) | Diff |
|---|---|---|---|---|
| android | hermes | arm64-v8a | 17,657,478 | +12,413 |
| android | hermes | armeabi-v7a | n/a | -- |
| android | hermes | x86 | n/a | -- |
| android | hermes | x86_64 | n/a | -- |
| android | jsc | arm64-v8a | 21,039,156 | +12,418 |
| android | jsc | armeabi-v7a | n/a | -- |
| android | jsc | x86 | n/a | -- |
| android | jsc | x86_64 | n/a | -- |
Base commit: 98e7ecd28057c8283778d81d68aafd4d9834917a Branch: main
I'm not 100% convinced we need to use the Error callback for this, given the overheads it requires.
Consider the JS equivalent of this type of module invocation to be something like this, which also will not call the original reject handler.
new Promise(() => {
setTimeout(() => {
throw new Error('foo');
}, 0)
).catch(err => console.error('error' + err));
My perspective is that the behaviour for all async native module calls in RN should be a Logbox in development (which we should fix if that's not happening here!) and a RN fatal error in production (which apps can still decide to handle by restarting the React app - without crashing the entire app).
Making native exceptions more actionable in development by capturing a JS stacktrace to associate with it is a great idea by the way. I just don't think we want to do it in production - potentially we could make it a runtime flag so app authors can decide?
@RSNara @javache Thank you for the feedback. I understand the performance overhead. I would like to go the runtime flag way. Could you point me toward an existing runtime flag where I could add this one?
@javache Yes, it would be an improved experience compared to standard JS behavior.
On Android, we use ReactFeatureFlags - which you'll need to use JNI to access - see getFeatureFlag - on iOS, we use global helpers like RCTEnableTurboModule to control feature flags.
I've updated the PR base on the comments. Thanks a lot for all the helpful suggestions. What do you think about the changes?
Examples of newly handled errors propagated to JS.
NSError
{
"name": "Error",
"message": "Exception in HostFunction: <unknown>",
"stack": "",
"cause": {
"userInfo": {
"Error reason": "Invalid Input"
},
"domain": "com.eezytutorials.iosTuts",
"code": "200"
}
}
std::exception
{
"name": "Error",
"message": "Exception in HostFunction: Output of e.what()",
"stack": "",
"cause": {
"message": "Output of e.what()"
}
}
Unknow Cpp exception
{
"name": "Error",
"message": "Exception in HostFunction: Unknown C++ exception thrown.",
"stack": "",
"cause": {
"message": "Unknown C++ exception thrown."
}
}
Thank you for the feedback, I'm sorry for the delay, I'll fix the comments this week.
Sorry for the delay on the reviews! I'd recommend splitting this in an iOS and Android PR to simplify the review.
@javache Thank you for all the suggestions, corrections, and nits. I appreciate it.
I've removed iOS changes from this PR making it Android only. I'll link PR with iOS changes here.
Let me know if there is anything more! 🙏
iOS PR Here
@javache
Is it expected that even "regular" rejects (eg the module implementation explicitly rejecting the promise) including the original caller's JS stack? Does that align with behaviour we see in other JS engines?
I would say it's expected because to get the caller's JS stack the user had to enable the feature by the flag traceTurboModulePromiseRejections that explicitly says trace promise rejections (meaning bot explicit rejections and ones based on thrown exception)
@javache I don't know how exactly to use the new AsyncCallback. I'm getting undefined in JS instead of the JSError.
https://github.com/facebook/react-native/pull/37484#discussion_r1369906105
@javache Thanks for the notes, I made the updates, let me know what you think. 🙏
@javache has imported this pull request. If you are a Meta employee, you can view this diff on Phabricator.
@javache merged this pull request in facebook/react-native@7612e6601a1c402c548dfe2fe548b1c481659993.