Repository: denoland/deno
Pull request: fix(ext/napi): prevent spurious TypeError from napi_coerce_to_object on null/undefined

Fixes #31306
Fixes #28682

- `napi_coerce_to_object(null/undefined)` was letting V8's `ToObject()` throw a TypeError, which got stored in `env.last_exception` by the `napi_wrap!` try-catch, then re-thrown by `call_fn` as a spurious unhandled rejection
- This masked real errors in NAPI modules like rolldown — plugin errors showed "Cannot convert undefined or null to object" instead of the actual error message
- Fix: check for null/undefined before calling `to_object()`, returning `napi_invalid_arg` without generating a V8 exception
- Added regression test that verifies `napi_coerce_to_object(null/undefined)` returns an error status without leaving a pending exception

Changed files:
filename: ext/napi/js_native_api.rs
patch: @@ -2447,7 +2447,15 @@ fn napi_coerce_to_object<'s>(
   check_arg!(env, result);
 
   v8::callback_scope!(unsafe scope, env.context());
-  let Some(coerced) = value.unwrap().to_object(scope) else {
+  let val = value.unwrap();
+  // Check for null/undefined before calling to_object() to avoid
+  // V8 generating a TypeError exception that would get stored in
+  // env.last_exception and later re-thrown as a spurious unhandled
+  // rejection (ECMAScript's ToObject throws for null/undefined).
+  if val.is_null_or_undefined() {
+    return napi_invalid_arg;
+  }
+  let Some(coerced) = val.to_object(scope) else {
     return napi_object_expected;
   };
 

filename: tests/napi/coerce_test.js
patch: @@ -72,3 +72,14 @@ Deno.test("napi coerce object", function () {
   );
   assertEquals(coerce.test_coerce_object({}), {});
 });
+
+// Regression test for https://github.com/denoland/deno/issues/31306
+// napi_coerce_to_object(null/undefined) should return an error status
+// without leaving a pending exception that would later be re-thrown
+// as a spurious "Cannot convert undefined or null to object" TypeError.
+Deno.test("napi coerce object null/undefined no pending exception", function () {
+  assertEquals(
+    coerce.test_coerce_object_null_no_pending_exception(null, undefined),
+    true,
+  );
+});

filename: tests/napi/src/coerce.rs
patch: @@ -2,6 +2,7 @@
 
 use std::ptr;
 
+use napi_sys::Status::napi_ok;
 use napi_sys::*;
 
 use crate::assert_napi_ok;
@@ -55,12 +56,49 @@ extern "C" fn test_coerce_string(
   assert_napi_ok!(napi_coerce_to_string(env, args[0], &mut value));
   value
 }
+
+/// Calls napi_coerce_to_object on null and undefined, verifying that:
+/// 1. It returns an error status (not napi_ok)
+/// 2. No pending exception is left behind
+extern "C" fn test_coerce_object_null_no_pending_exception(
+  env: napi_env,
+  info: napi_callback_info,
+) -> napi_value {
+  let (args, argc, _) = napi_get_callback_info!(env, info, 2);
+  assert_eq!(argc, 2);
+
+  // args[0] should be null, args[1] should be undefined
+  for i in 0..2 {
+    let mut result: napi_value = ptr::null_mut();
+    let status = unsafe { napi_coerce_to_object(env, args[i], &mut result) };
+    // Should fail
+    assert_ne!(status, napi_ok);
+
+    // Verify no pending exception was left behind
+    let mut is_pending = false;
+    assert_napi_ok!(napi_is_exception_pending(env, &mut is_pending));
+    assert!(
+      !is_pending,
+      "napi_coerce_to_object should not leave a pending exception"
+    );
+  }
+
+  let mut ret: napi_value = ptr::null_mut();
+  assert_napi_ok!(napi_get_boolean(env, true, &mut ret));
+  ret
+}
+
 pub fn init(env: napi_env, exports: napi_value) {
   let properties = &[
     napi_new_property!(env, "test_coerce_bool", test_coerce_bool),
     napi_new_property!(env, "test_coerce_number", test_coerce_number),
     napi_new_property!(env, "test_coerce_object", test_coerce_object),
     napi_new_property!(env, "test_coerce_string", test_coerce_string),
+    napi_new_property!(
+      env,
+      "test_coerce_object_null_no_pending_exception",
+      test_coerce_object_null_no_pending_exception
+    ),
   ];
 
   assert_napi_ok!(napi_define_properties(