Skip to content

Commit 7612e66

Browse files
feat(tm-android): Reject Promise if Turbo Module method throws an Error (#37484)
Summary: ### [iOS change here](#40764) This PR builds upon the previous work done in #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: <!-- Help reviewers and the release process by writing your own changelog entry. Pick one each for the category and type tags: [ANDROID|GENERAL|IOS|INTERNAL] [BREAKING|ADDED|CHANGED|DEPRECATED|REMOVED|FIXED|SECURITY] - Message For more details, see: https://reactnative.dev/contributing/changelogs-in-pull-requests --> [GENERAL][ADDED] - Turbo Modules Promise-returning functions reject with JS and platform stack traces information Pull Request resolved: #37484 Test Plan: | Android | |--------| | ![function_promise_android](https://github.com/krystofwoldrich/react-native/assets/31292499/1d1a3adf-986a-47b4-b98b-9e766176b7ae) | 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" } } ``` How I logged out the Errors: ```js console.log('Error in JS:', JSON.stringify({ name: e.name, message: e.message, stack: e.stack, ...e, }, null, 2)); ``` Reviewed By: RSNara Differential Revision: D50613349 Pulled By: javache fbshipit-source-id: b49c469118c8d8d27c43164f110dfe57ddd592d9
1 parent daedbe6 commit 7612e66

File tree

3 files changed

+103
-16
lines changed

3 files changed

+103
-16
lines changed

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/config/ReactFeatureFlags.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
package com.facebook.react.config;
99

1010
import com.facebook.proguard.annotations.DoNotStripAny;
11+
import com.facebook.react.common.build.ReactBuildConfig;
1112

1213
/**
1314
* Hi there, traveller! This configuration class is not meant to be used by end-users of RN. It
@@ -161,4 +162,16 @@ public class ReactFeatureFlags {
161162
* priorities from any thread.
162163
*/
163164
public static boolean useModernRuntimeScheduler = false;
165+
166+
/**
167+
* Enables storing js caller stack when creating promise in native module. This is useful in case
168+
* of Promise rejection and tracing the cause.
169+
*/
170+
public static boolean traceTurboModulePromiseRejections = ReactBuildConfig.DEBUG;
171+
172+
/**
173+
* Enables auto rejecting promises from Turbo Modules method calls. If native error occurs Promise
174+
* in JS will be rejected (The JS error will include native stack)
175+
*/
176+
public static boolean rejectTurboModulePromiseOnNativeError = true;
164177
}

packages/react-native/ReactCommon/react/nativemodule/core/platform/android/ReactCommon/JavaTurboModule.cpp

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,28 @@ JavaTurboModule::~JavaTurboModule() {
5858

5959
namespace {
6060

61+
constexpr auto kReactFeatureFlagsJavaDescriptor =
62+
"com/facebook/react/config/ReactFeatureFlags";
63+
64+
bool getFeatureFlagBoolValue(const char* name) {
65+
static const auto reactFeatureFlagsClass =
66+
facebook::jni::findClassStatic(kReactFeatureFlagsJavaDescriptor);
67+
const auto field = reactFeatureFlagsClass->getStaticField<jboolean>(name);
68+
return reactFeatureFlagsClass->getStaticFieldValue(field);
69+
}
70+
71+
bool traceTurboModulePromiseRejections() {
72+
static bool traceRejections =
73+
getFeatureFlagBoolValue("traceTurboModulePromiseRejections");
74+
return traceRejections;
75+
}
76+
77+
bool rejectTurboModulePromiseOnNativeError() {
78+
static bool rejectOnError =
79+
getFeatureFlagBoolValue("rejectTurboModulePromiseOnNativeError");
80+
return rejectOnError;
81+
}
82+
6183
struct JNIArgs {
6284
JNIArgs(size_t count) : args_(count) {}
6385
std::vector<jvalue> args_;
@@ -396,7 +418,7 @@ jsi::Value createJSRuntimeError(
396418
*/
397419
jsi::JSError convertThrowableToJSError(
398420
jsi::Runtime& runtime,
399-
jni::local_ref<jni::JThrowable> throwable) {
421+
jni::alias_ref<jni::JThrowable> throwable) {
400422
auto stackTrace = throwable->getStackTrace();
401423

402424
jsi::Array stackElements(runtime, stackTrace->size());
@@ -424,6 +446,22 @@ jsi::JSError convertThrowableToJSError(
424446
return {runtime, std::move(error)};
425447
}
426448

449+
void rejectWithException(
450+
AsyncCallback<>& reject,
451+
std::exception_ptr exception,
452+
std::optional<std::string>& jsInvocationStack) {
453+
auto throwable = jni::getJavaExceptionForCppException(exception);
454+
reject.call([jsInvocationStack, throwable = jni::make_global(throwable)](
455+
jsi::Runtime& rt, jsi::Function& jsFunction) {
456+
auto jsError = convertThrowableToJSError(rt, throwable);
457+
if (jsInvocationStack.has_value()) {
458+
jsError.value().asObject(rt).setProperty(
459+
rt, "stack", jsInvocationStack.value());
460+
}
461+
jsFunction.call(rt, jsError.value());
462+
});
463+
}
464+
427465
} // namespace
428466

429467
jsi::Value JavaTurboModule::invokeJavaMethod(
@@ -783,6 +821,10 @@ jsi::Value JavaTurboModule::invokeJavaMethod(
783821
jsi::Function Promise =
784822
runtime.global().getPropertyAsFunction(runtime, "Promise");
785823

824+
// The callback is used for auto rejecting if error is caught from method
825+
// invocation
826+
std::optional<AsyncCallback<>> nativeRejectCallback;
827+
786828
// The promise constructor runs its arg immediately, so this is safe
787829
jobject javaPromise;
788830
jsi::Value jsPromise = Promise.callAsConstructor(
@@ -799,6 +841,13 @@ jsi::Value JavaTurboModule::invokeJavaMethod(
799841
throw jsi::JSError(runtime, "Incorrect number of arguments");
800842
}
801843

844+
if (rejectTurboModulePromiseOnNativeError()) {
845+
nativeRejectCallback = AsyncCallback(
846+
runtime,
847+
args[1].getObject(runtime).getFunction(runtime),
848+
jsInvoker_);
849+
}
850+
802851
auto resolve = createJavaCallback(
803852
runtime,
804853
args[0].getObject(runtime).getFunction(runtime),
@@ -817,14 +866,25 @@ jsi::Value JavaTurboModule::invokeJavaMethod(
817866
env->DeleteLocalRef(javaPromise);
818867
jargs[argCount].l = globalPromise;
819868

869+
// JS Stack at the time when the promise is created.
870+
std::optional<std::string> jsInvocationStack;
871+
if (traceTurboModulePromiseRejections()) {
872+
jsInvocationStack = createJSRuntimeError(runtime, "")
873+
.asObject(runtime)
874+
.getProperty(runtime, "stack")
875+
.toString(runtime)
876+
.utf8(runtime);
877+
}
878+
820879
const char* moduleName = name_.c_str();
821880
const char* methodName = methodNameStr.c_str();
822881
TMPL::asyncMethodCallArgConversionEnd(moduleName, methodName);
823-
824882
TMPL::asyncMethodCallDispatch(moduleName, methodName);
825883
nativeMethodCallInvoker_->invokeAsync(
826884
methodName,
827885
[jargs,
886+
rejectCallback = std::move(nativeRejectCallback),
887+
jsInvocationStack = std::move(jsInvocationStack),
828888
globalRefs,
829889
methodID,
830890
instance_ = jni::make_weak(instance_),
@@ -856,7 +916,14 @@ jsi::Value JavaTurboModule::invokeJavaMethod(
856916
FACEBOOK_JNI_THROW_PENDING_EXCEPTION();
857917
} catch (...) {
858918
TMPL::asyncMethodCallExecutionFail(moduleName, methodName, id);
859-
throw;
919+
if (rejectTurboModulePromiseOnNativeError() && rejectCallback) {
920+
auto exception = std::current_exception();
921+
rejectWithException(
922+
*rejectCallback, exception, jsInvocationStack);
923+
rejectCallback = std::nullopt;
924+
} else {
925+
throw;
926+
}
860927
}
861928

862929
for (auto globalRef : globalRefs) {

packages/rn-tester/js/examples/TurboModule/SampleTurboModuleExample.js

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ class SampleTurboModuleExample extends React.Component<{||}, State> {
5454
rejectPromise: () =>
5555
NativeSampleTurboModule.getValueWithPromise(true)
5656
.then(() => {})
57-
.catch(e => this._setResult('rejectPromise', e.message)),
57+
.catch(e => {
58+
console.error(e);
59+
this._setResult('rejectPromise', e.message);
60+
}),
5861
getConstants: () => NativeSampleTurboModule.getConstants(),
5962
voidFunc: () => NativeSampleTurboModule.voidFunc(),
6063
getBool: () => NativeSampleTurboModule.getBool(true),
@@ -81,45 +84,49 @@ class SampleTurboModuleExample extends React.Component<{||}, State> {
8184
try {
8285
NativeSampleTurboModule.voidFuncThrows?.();
8386
} catch (e) {
87+
console.error(e);
8488
return e.message;
8589
}
8690
},
8791
getObjectThrows: () => {
8892
try {
8993
NativeSampleTurboModule.getObjectThrows?.({a: 1, b: 'foo', c: null});
9094
} catch (e) {
95+
console.error(e);
9196
return e.message;
9297
}
9398
},
9499
promiseThrows: () => {
95-
try {
96-
// $FlowFixMe[unused-promise]
97-
NativeSampleTurboModule.promiseThrows?.();
98-
} catch (e) {
99-
return e.message;
100-
}
100+
NativeSampleTurboModule.promiseThrows?.()
101+
.then(() => {})
102+
.catch(e => {
103+
console.error(e);
104+
this._setResult('promiseThrows', e.message);
105+
});
101106
},
102107
voidFuncAssert: () => {
103108
try {
104109
NativeSampleTurboModule.voidFuncAssert?.();
105110
} catch (e) {
111+
console.error(e);
106112
return e.message;
107113
}
108114
},
109115
getObjectAssert: () => {
110116
try {
111117
NativeSampleTurboModule.getObjectAssert?.({a: 1, b: 'foo', c: null});
112118
} catch (e) {
119+
console.error(e);
113120
return e.message;
114121
}
115122
},
116123
promiseAssert: () => {
117-
try {
118-
// $FlowFixMe[unused-promise]
119-
NativeSampleTurboModule.promiseAssert?.();
120-
} catch (e) {
121-
return e.message;
122-
}
124+
NativeSampleTurboModule.promiseAssert?.()
125+
.then(() => {})
126+
.catch(e => {
127+
console.error(e);
128+
this._setResult('promiseAssert', e.message);
129+
});
123130
},
124131
};
125132

0 commit comments

Comments
 (0)