Fix napi_get_property_names conformance on JSC, ChakraCore and QuickJS - #218
Conversation
napi_get_property_names is specified to return the enumerable string-keyed properties of an object *and of its prototype chain* -- the same set a `for...in` loop visits. Only the V8 backend did that, via GetPropertyNames configured with kIncludePrototypes | ONLY_ENUMERABLE | SKIP_SYMBOLS. The other three each diverged: | backend | enumerable-only | includes prototypes | throws | |----------------|-----------------|---------------------|--------| | V8 | yes | yes | no | | JavaScriptCore | n/a | n/a | yes | | ChakraCore | no | no | no | | QuickJS | yes | no | no | JavaScriptCore was outright broken: it called Object.getOwnPropertyNames with argc 0, so the `object` argument was never used and the call always threw "TypeError: undefined is not an object". ChakraCore used JsGetOwnPropertyNames, which is own-only and also reports non-enumerable properties. QuickJS used JS_GetOwnPropertyNames with JS_GPN_ENUM_ONLY, which is enumerable-only but still own-only. None of the three engines exposes a native equivalent of V8's key collection, so add a single shared implementation that walks the prototype chain explicitly, written purely against the public napi_* surface, and have all three backends delegate to it. Per level it takes Object.keys for the properties `for...in` reports, and Object.getOwnPropertyNames for the shadowing set: a non-enumerable own property is not reported itself, but it does hide a same-named enumerable property further up the chain. JavaScriptCore's JSObjectCopyPropertyNames was considered for that backend since it walks the chain natively, but it drops the shadowing rule, so the shared walk is used there too and all backends stay consistent. Adds nine script tests, exercised through a new napiGetPropertyNames global that calls Napi::Object::GetPropertyNames. Verified locally on ChakraCore and QuickJS (225 passing, 0 failing on both). As a negative control, five of the nine fail when the old ChakraCore implementation is restored. Fixes BabylonJS#216 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
There was a problem hiding this comment.
Pull request overview
This PR aligns napi_get_property_names behavior across the JavaScriptCore, ChakraCore, and QuickJS backends to match the Node-API/V8 semantics: enumerable, string-keyed property names including the prototype chain (i.e., matching for...in, including the “non-enumerable own property shadows inherited enumerable” rule).
Changes:
- Added an engine-agnostic implementation (
napi_shared::GetEnumerablePropertyNames) that walks the prototype chain usingObject.keysplusObject.getOwnPropertyNamesto enforcefor...in-equivalent semantics. - Updated JavaScriptCore, ChakraCore, and QuickJS
napi_get_property_namesimplementations to delegate to the shared helper (and added missingCHECK_ARG(env, object)on JSC/Chakra). - Added script-level regression tests and a test harness global (
napiGetPropertyNames) to validate behavior againstfor...in.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| Tests/UnitTests/Shared/Shared.cpp | Exposes napiGetPropertyNames to script tests via the C++ harness. |
| Tests/UnitTests/Scripts/tests.ts | Adds regression tests for napi_get_property_names semantics (prototype chain, enumerability, symbols, shadowing, arrays). |
| Core/Node-API/Source/js_native_api_shared.h | Declares shared helper for napi_get_property_names semantics. |
| Core/Node-API/Source/js_native_api_shared.cc | Implements the shared prototype-chain walk using only public napi_* APIs. |
| Core/Node-API/Source/js_native_api_quickjs.cc | Replaces own-only QuickJS implementation with shared helper. |
| Core/Node-API/Source/js_native_api_javascriptcore.cc | Fixes JSC implementation (previously throwing) by delegating to shared helper and validating object. |
| Core/Node-API/Source/js_native_api_chakra.cc | Replaces own-only / non-enumerable-including Chakra implementation with shared helper and validates object. |
| Core/Node-API/CMakeLists.txt | Adds shared helper sources to non-V8 backend builds. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…s broken Two follow-ups from CI on the previous commit. napi_get_prototype on JavaScriptCore ran the result of JSObjectGetPrototype through JSValueToObject. At the top of a prototype chain that value is `null`, so the conversion threw "TypeError: null is not an object" instead of reporting the end of the chain, which made the chain impossible to walk and failed all nine new tests. Return the raw prototype value, as V8 does. It has no other callers in this repository: Blob.cpp deliberately uses Object.getPrototypeOf instead. The "matches for...in" assertion also failed on Hermes, but in the opposite direction: napi_get_property_names returned the correct ['own', 'middle'] while Hermes' own `for...in` returned ['own', 'middle', 'deep'], i.e. Hermes does not implement the rule that a non-enumerable own property shadows an inherited enumerable one. Probe for that behaviour at runtime rather than naming engines, and only use `for...in` as an oracle where it holds. The explicit expected-value assertion still runs everywhere. Re-verified on ChakraCore and QuickJS: 225 passing, 0 failing on both. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
The JSI adapter provides the Napi C++ surface directly on top of JSI rather
than over the C Node-API, and Object::GetPropertyNames was still a stub that
threw std::runtime_error{"TODO"}, surfacing in script as
"Error: Exception in HostFunction: TODO".
jsi::Object::getPropertyNames returns the enumerable string-keyed properties
of an object and of its prototype chain, which is exactly the specified
behaviour, so forward to it.
Verified locally against the ReactNative.V8Jsi runtime: all nine
napi_get_property_names tests pass, 225 passing, 0 failing.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
Review feedback: the implementation coerces its argument with ToObject, but nothing covered that. `Napi::Object::GetPropertyNames` can only be called on an already-constructed `Napi::Object`, so the C++ harness structurally could not reach the coercion path. Expose the C entry point directly as `napiGetPropertyNamesRaw` and pass the raw value through. The Node-API-JSI backend implements the `Napi::` C++ surface straight on top of JSI and has no C Node-API at all, so the global is left undefined there (it already has a `JSRUNTIMEHOST_NAPI_ENGINE_JSI` define) and the six new tests skip themselves. Testing that also exposed a divergence for `null` and `undefined`, which have no object wrapper: V8 reports `napi_object_expected`, QuickJS's `napi_coerce_to_object` uses `Object(value)` and happily returns an empty object, and JavaScriptCore's throws. Check `napi_typeof` explicitly in the shared implementation so all three match V8 instead. Verified locally: ChakraCore 231 passing / 0 failing, QuickJS 231 / 0, JSI 225 / 0 with 6 pending. Negative control: with the null/undefined check removed, QuickJS fails exactly the two tests that cover it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
Hermes' Node-API is not implemented in this repository -- it comes from the Hermes dependency itself -- and it rejects primitives outright instead of applying ToObject, so the three wrapping tests failed there. It does reject null and undefined like everyone else, so those two still run. Hermes is documented as an experimental engine here and its napi is not ours to fix, so skip those cases explicitly rather than weakening the assertions for every backend. That needs an engine identifier in script, so plumb NAPI_JAVASCRIPT_ENGINE through as a `napiEngine` global alongside the existing `hostPlatform` one. Verified locally: ChakraCore 231 passing / 0 failing, QuickJS 231 / 0, JSI 225 / 0 with 6 pending. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
Shared.cpp is compiled by two targets -- UnitTests and Android's UnitTestsJNI -- and only the former got the new define, so all four Android legs failed with "use of undeclared identifier 'JSRUNTIMEHOST_NAPI_ENGINE'". Mirror it next to JSRUNTIMEHOST_PLATFORM, exactly as that one is already handled in both places. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
Core/Node-API/Source/js_native_api_chakra.cc:688
napi_shared::GetEnumerablePropertyNamescan returnnapi_object_expected(for null/undefined) without settingenv->last_error. SinceCHECK_NAPIassumes last_error is already set, callers may observe stalenapi_get_last_error_infodata. Set last_error explicitly for this status before returning it.
// `JsGetOwnPropertyNames` is own-only and includes non-enumerable properties,
// so use the shared prototype-chain walk instead.
CHECK_NAPI(napi_shared::GetEnumerablePropertyNames(env, object, result));
Core/Node-API/Source/js_native_api_javascriptcore.cc:976
napi_shared::GetEnumerablePropertyNamescan returnnapi_object_expected(for null/undefined) without settingenv->last_error. BecauseCHECK_NAPIassumes last_error was already set, this can leave stale last-error info visible vianapi_get_last_error_infowhen an error is returned. Set last_error explicitly for this status before returning.
// JavaScriptCore's `JSObjectCopyPropertyNames` walks the prototype chain but
// silently drops properties shadowed by a non-enumerable own property, so use
// the shared prototype-chain walk instead.
CHECK_NAPI(napi_shared::GetEnumerablePropertyNames(env, object, result));
return napi_ok;
Tests/UnitTests/Shared/Shared.cpp:142
- When
napi_get_property_namesfails with a pending JavaScript exception, the current code clears and discards the original exception and instead throws a generic status-based error. This makes debugging failures harder and can hide the real engine error. If an exception is pending, rethrow that exception value instead of discarding it.
// A failed call may or may not have left a JavaScript
// exception pending; surface either as a thrown error so
// that the script tests can assert on it uniformly.
bool isExceptionPending{};
if (napi_is_exception_pending(rawEnv, &isExceptionPending) == napi_ok && isExceptionPending)
{
napi_value error{};
napi_get_and_clear_last_exception(rawEnv, &error);
}
Core/Node-API/Source/js_native_api_quickjs.cc:1403
napi_shared::GetEnumerablePropertyNamescan returnnapi_object_expected(for null/undefined) without settingenv->last_error. BecauseCHECK_NAPIassumes the callee already set last_error, this can leave stale last-error info visible vianapi_get_last_error_infoeven though the API returned an error. Handle this status explicitly and set last_error before returning it.
// `JS_GetOwnPropertyNames` is own-only, so use the shared prototype-chain
// walk instead.
CHECK_NAPI(napi_shared::GetEnumerablePropertyNames(env, object, result));
napi_clear_last_error(env);
Fixes #216.
napi_get_property_namesis specified to return the enumerable string-keyed properties of an object and of its prototype chain — the same set afor...inloop visits. Only the V8 backend did that. Every other backend diverged:TODOstubObject.getOwnPropertyNameswithargc0, so theobjectargument was never used and the call always threwTypeError: undefined is not an object.JsGetOwnPropertyNames, which is own-only and also reports non-enumerable properties.JS_GetOwnPropertyNameswithJS_GPN_ENUM_ONLY— enumerable-only, but still own-only.Object::GetPropertyNamesas an unimplemented stub that threwstd::runtime_error{"TODO"}.Approach
None of JSC, ChakraCore or QuickJS exposes a native equivalent of V8's
kIncludePrototypes | ONLY_ENUMERABLE | SKIP_SYMBOLSkey collection, so rather than write three subtly different prototype walks, this adds one shared implementation (Source/js_native_api_shared.{h,cc}) written purely against the publicnapi_*surface, and has all three delegate to it.Per prototype level it takes:
Object.keys— the propertiesfor...inreports at that level, already in specification order;Object.getOwnPropertyNames— the shadowing set. A non-enumerable own property is not reported itself, but it does hide a same-named enumerable property further up the chain.JSObjectCopyPropertyNameswas considered for JavaScriptCore since it walks the chain natively, but it drops that shadowing rule, so the shared walk is used there too and all backends stay consistent. The input is coerced withnapi_coerce_to_object, matching V8'sCHECK_TO_OBJECT.JSI keeps its own path:
jsi::Object::getPropertyNamesalready has exactly the specified semantics, soObject::GetPropertyNamesjust forwards to it.Two further bugs this uncovered
1.
napi_get_prototypeon JavaScriptCore (fixed here — the shared walk depends on it). It ran the result ofJSObjectGetPrototypethroughJSValueToObject. At the top of a prototype chain that value isnull, so the conversion threwTypeError: null is not an objectinstead of reporting the end of the chain, making the chain impossible to walk. It now returns the raw prototype value, as V8 does. It had no other callers in this repository —Blob.cppdeliberately usesObject.getPrototypeOfinstead.2. Hermes'
for...indoes not implement the shadowing rule (not fixed — engine bug). CI showednapi_get_property_namesreturning the correct['own', 'middle']while Hermes' ownfor...inreturned['own', 'middle', 'deep'], i.e. it reports an inherited property that a non-enumerable own property is supposed to hide. The test therefore probes for that behaviour at runtime rather than naming engines, and only usesfor...inas an oracle where it holds; the explicit expected-value assertion still runs everywhere. Happy to file this separately if useful.Testing
Nine script tests, exercised through a new
napiGetPropertyNamesglobal that callsNapi::Object::GetPropertyNames:lengthomittedfor...inover a multi-level prototype chainVerified locally on ChakraCore, QuickJS and JSI — 225 passing, 0 failing on each. JavaScriptCore, V8 and Hermes are covered by CI; all 25 legs are green.
As a negative control, restoring the old ChakraCore implementation makes five of the nine fail, confirming the tests are load-bearing rather than vacuous:
Note on scope
napi_get_all_property_namesis still only implemented by the V8 backend; the others would fail to link if a consumer called it. That is a separate gap and is left alone here.