Opening the camera in our app fires camera, microphone, and location permission requests from the same useEffect. On Android the JS console gets these almost immediately:
ERROR [Error: Uncaught (in promise, id: 0) Error: java.lang.RuntimeException: Timeouted: JPromise was destroyed!
at com.facebook.jni.HybridData$Destructor.deleteNative(Native Method)
at com.facebook.jni.HybridData$Destructor.destruct(HybridData.java:82)
at com.facebook.jni.DestructorThread$1.run(DestructorThread.java:78)
]
ERROR [Error: Uncaught (in promise, id: 1) Error: java.lang.RuntimeException: Timeouted: JPromise was destroyed!
... (same)
ERROR [Error: Uncaught (in promise, id: 2) Error: java.lang.RuntimeException: Timeouted: JPromise was destroyed!
... (same)
The dialogs themselves show, user grants/denies, and the hook selectors (hasPermission / status) update correctly. Only the requestPermission() promises hang.
Setup
const camera = useCameraPermission();
const mic = useMicrophonePermission();
const location = useLocation(); // from react-native-vision-camera-location
useEffect(() => {
if (!visible) return;
if (camera.status === 'not-determined') void camera.requestPermission();
if (mic.status === 'not-determined') void mic.requestPermission();
if (!location.hasPermission) void location.requestPermission();
}, [visible, camera, mic, location]);
| Package |
Version |
| react-native-vision-camera |
5.0.8 |
| react-native-vision-camera-location |
5.0.8 |
| react-native-nitro-modules |
0.35.6 |
| react-native |
0.83.6 |
| Tested on |
Android 14 + Android 15 |
Diagnosis
packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/ReactApplicationContext+permissions.kt:
suspend fun ReactApplicationContext.requestPermission(permission: String): Boolean {
return suspendCoroutine { continuation ->
val activity = currentActivity ?: throw Error("No Activity!")
if (activity is PermissionAwareActivity) {
...
val currentRequestCode = permissionRequestCode++
val listener = PermissionListener { requestCode, _, grantResults ->
if (requestCode == currentRequestCode) {
...
continuation.resume(hasPermission)
return@PermissionListener true
}
return@PermissionListener false
}
activity.requestPermissions(arrayOf(permission), currentRequestCode, listener)
}
}
}
Each call passes its own PermissionListener, but RN's PermissionAwareActivity only stores one in-flight PermissionListener field. Calling requestPermissions(...) again before the first result comes back overwrites the previous listener. With three parallel calls, listener A is replaced by B, B is replaced by C. Only listener C's continuation resumes when the user finishes the dialogs; A and B's coroutines suspend forever, the JPromises stay pending until GC, and Nitro's JPromise.hpp destructor produces the rejection above:
~JPromise() override {
if (isPending()) [[unlikely]] {
std::runtime_error error("Timeouted: JPromise was destroyed!");
this->reject(...);
}
}
The "Timeouted" wording is misleading; there's no actual timeout, it's the destructor catching a never-resolved promise.
Suggested fix
Keep a Map<Int, Continuation<Boolean>> keyed by requestCode and a single dispatcher PermissionListener that resumes whichever continuation matches the incoming code. Sketch:
private val pendingContinuations = ConcurrentHashMap<Int, Continuation<Boolean>>()
private val dispatcher = PermissionListener { requestCode, _, grantResults ->
val cont = pendingContinuations.remove(requestCode) ?: return@PermissionListener false
cont.resume(grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED)
true
}
requestPermission(...) then registers its continuation in the map and passes the same dispatcher every time, so PermissionAwareActivity's single-listener slot only ever holds the dispatcher.
Alternatively serialize with a Mutex so requests run one at a time. Simpler but the dialogs show sequentially which is worse UX.
Workaround for now
Catching the rejection silences the noise, since the hook's hasPermission / status is the actual source of truth:
if (camera.status === 'not-determined') void camera.requestPermission().catch(() => {});
if (mic.status === 'not-determined') void mic.requestPermission().catch(() => {});
if (!location.hasPermission) void location.requestPermission().catch(() => {});
Happy to send a PR with the dispatcher approach if it's the direction you want to take.
Opening the camera in our app fires camera, microphone, and location permission requests from the same
useEffect. On Android the JS console gets these almost immediately:The dialogs themselves show, user grants/denies, and the hook selectors (
hasPermission/status) update correctly. Only therequestPermission()promises hang.Setup
Diagnosis
packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/ReactApplicationContext+permissions.kt:Each call passes its own
PermissionListener, but RN'sPermissionAwareActivityonly stores one in-flightPermissionListenerfield. CallingrequestPermissions(...)again before the first result comes back overwrites the previous listener. With three parallel calls, listener A is replaced by B, B is replaced by C. Only listener C's continuation resumes when the user finishes the dialogs; A and B's coroutines suspend forever, the JPromises stay pending until GC, and Nitro'sJPromise.hppdestructor produces the rejection above:The "Timeouted" wording is misleading; there's no actual timeout, it's the destructor catching a never-resolved promise.
Suggested fix
Keep a
Map<Int, Continuation<Boolean>>keyed byrequestCodeand a single dispatcherPermissionListenerthat resumes whichever continuation matches the incoming code. Sketch:requestPermission(...)then registers its continuation in the map and passes the same dispatcher every time, soPermissionAwareActivity's single-listener slot only ever holds the dispatcher.Alternatively serialize with a
Mutexso requests run one at a time. Simpler but the dialogs show sequentially which is worse UX.Workaround for now
Catching the rejection silences the noise, since the hook's
hasPermission/statusis the actual source of truth:Happy to send a PR with the dispatcher approach if it's the direction you want to take.