From ad25bb8502fe169214dd066f6dd0b1c87b1f1807 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Fri, 13 Nov 2026 13:45:35 +0100 Subject: [PATCH] Add a JSI interface to read the properties of an object with their values IPropertyEnumeration::forEachProperty calls a callback with the name and the value of each enumerable property of an object. The result is the same as calling getProperty for each name of getPropertyNames, without the names array and a String for each name. It's a separate, optional interface, so IRuntime and its ABI don't change. A runtime without it returns nullptr from castInterface, and the caller uses getPropertyNames. It isn't behind JSI_UNSTABLE: if the design has to change after a release, a runtime can stop returning this interface and provide a new one, and callers fall back to getPropertyNames in the meantime. The callback gets the name like getStringData gives the data: an ascii flag, then char or char16_t units, so names aren't converted to UTF-8 and every name comes through unchanged. The callback may use the runtime, so Hermes copies each name into a reused buffer. --- API/hermes/hermes.cpp | 89 +++++++++++++++++++- API/jsi/jsi/jsi.h | 43 ++++++++++ unittests/API/APITest.cpp | 167 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 297 insertions(+), 2 deletions(-) diff --git a/API/hermes/hermes.cpp b/API/hermes/hermes.cpp index 0f96516c5f2..e7b409b75c8 100644 --- a/API/hermes/hermes.cpp +++ b/API/hermes/hermes.cpp @@ -293,7 +293,8 @@ class HermesRuntimeImpl final : public HermesRuntime, private IHermesTestHelpers, private InstallHermesFatalErrorHandler, private jsi::Instrumentation, - public ISetEventLoopControl + public ISetEventLoopControl, + public jsi::IPropertyEnumeration #ifdef JSI_UNSTABLE , public jsi::ISerialization, @@ -768,8 +769,19 @@ class HermesRuntimeImpl final : public HermesRuntime, const jsi::Serialized &serialized) const override; const std::shared_ptr makeSerialized( vm::SerializedValue &value) const override; + #endif + void forEachProperty( + const jsi::Object &object, + void *ctx, + void (*cb)( + void *ctx, + bool ascii, + const void *name, + size_t length, + jsi::Value &value)) override; + void setEventLoopControl(IEventLoopControl *eventLoopControl) override; IEventLoopControl *getEventLoopControl() override; @@ -1728,7 +1740,9 @@ jsi::ICast *HermesRuntimeImpl::castInterface(const jsi::UUID &interfaceUUID) { return static_cast(this); } #endif - else if (interfaceUUID == ISetEventLoopControl::uuid) { + else if (interfaceUUID == IPropertyEnumeration::uuid) { + return static_cast(this); + } else if (interfaceUUID == ISetEventLoopControl::uuid) { return static_cast(this); } return nullptr; @@ -3118,6 +3132,77 @@ jsi::Array HermesRuntimeImpl::getPropertyNames(const jsi::Object &obj) { return ret; } +void HermesRuntimeImpl::forEachProperty( + const jsi::Object &object, + void *ctx, + void (*cb)( + void *ctx, + bool ascii, + const void *name, + size_t length, + jsi::Value &value)) { + ExecutionScopeRAII scopeRAII(mutatorScope); + vm::GCScope gcScope(runtime_); + uint32_t beginIndex; + uint32_t endIndex; + auto objHandle = handle(object); + vm::CallResult> cr = + vm::getForInPropertyNames(runtime_, objHandle, beginIndex, endIndex); + checkStatus(cr.getStatus()); + vm::Handle names = *cr; + + // The callback may use the runtime, which may move the strings, so each + // name is copied. The buffers are reused, so short names don't allocate. + std::string asciiName; + std::u16string utf16Name; + bool ascii = true; + auto copyName = [&](vm::StringView view) { + ascii = view.isASCII(); + if (ascii) { + asciiName.assign(view.castToCharPtr(), view.length()); + } else { + utf16Name.assign(view.castToChar16Ptr(), view.length()); + } + }; + + for (uint32_t i = beginIndex; i < endIndex; ++i) { + vm::GCScopeMarkerRAII marker{runtime_}; + vm::SmallHermesValue key = names->at(i); + jsi::Value value; + if (key.isSymbol()) { + // for-in keeps the names of ordinary objects as symbols. getProperty + // with a PropNameID reads them the same way. + vm::SymbolID id = key.getSymbol(); + copyName(runtime_.getIdentifierTable().getStringView(runtime_, id)); + auto valueRes = vm::JSObject::getNamedOrIndexed(objHandle, runtime_, id); + checkStatus(valueRes.getStatus()); + value = valueFromHermesValue(valueRes->get()); + } else { + vm::Handle<> keyHandle = runtime_.makeHandle(key.unboxToHV(runtime_)); + if (key.isString()) { + copyName(vm::StringPrimitive::createStringView( + runtime_, vm::Handle::vmcast(keyHandle))); + } else if (key.isNumber()) { + // An index, whose name is its decimal digits. + ascii = true; + asciiName = + std::to_string(static_cast(key.getNumber(runtime_))); + } else { + llvm_unreachable("property name is not String, Symbol or Number"); + } + auto valueRes = + vm::JSObject::getComputed_RJS(objHandle, runtime_, keyHandle); + checkStatus(valueRes.getStatus()); + value = valueFromHermesValue(valueRes->get()); + } + if (ascii) { + cb(ctx, true, asciiName.data(), asciiName.size(), value); + } else { + cb(ctx, false, utf16Name.data(), utf16Name.size(), value); + } + } +} + jsi::WeakObject HermesRuntimeImpl::createWeakObject(const jsi::Object &obj) { ExecutionScopeRAII scopeRAII(mutatorScope); return addWeak( diff --git a/API/jsi/jsi/jsi.h b/API/jsi/jsi/jsi.h index bf98028fb3f..40313e06150 100644 --- a/API/jsi/jsi/jsi.h +++ b/API/jsi/jsi/jsi.h @@ -329,6 +329,49 @@ class JSI_EXPORT ISerialization : public ICast { #endif // JSI_UNSTABLE +/// Provides an API to read the enumerable properties of an object with +/// their values, without the array of getPropertyNames or a String for each +/// name. +class JSI_EXPORT IPropertyEnumeration : public ICast { + public: + static constexpr jsi::UUID uuid{ + 0xdf3c7545, + 0x65b0, + 0x44b4, + 0xaa11, + 0xe03358f93907}; + + /// Invokes the callback \p cb with \p ctx and the name and value of each + /// enumerable string-keyed property of \p object and its prototype chain. + /// The result is the same as calling getProperty for each name that + /// getPropertyNames returns, in that order: the names are collected before + /// the first call, getters and Proxy traps run, a property added during the + /// enumeration is not visited, and a property deleted before it's visited is + /// still visited with the value that getProperty returns at that time. + /// + /// If \p ascii is true, \p name points to \p length ASCII characters. + /// Otherwise, it points to \p length UTF-16 code units, which may include + /// unpaired surrogates. The name is not null-terminated, and it is valid + /// only until \p cb returns. The callback may move \p value. + /// + /// The callback may use the runtime, for example to allocate, call + /// functions or modify \p object. If a getter or Proxy trap throws, the + /// enumeration stops and the method throws a JSError. If the callback throws, + /// the enumeration stops and the exception propagates to the caller. + virtual void forEachProperty( + const Object& object, + void* ctx, + void (*cb)( + void* ctx, + bool ascii, + const void* name, + size_t length, + Value& value)) = 0; + + protected: + ~IPropertyEnumeration() = default; +}; + /// An interface that provides various functionalities of the JS runtime. /// The APIs must not be called from multiple threads concurrently. It is the /// user's responsibility ensure thread safety when using IRuntime. diff --git a/unittests/API/APITest.cpp b/unittests/API/APITest.cpp index 3f9de8e32e5..e96feab5560 100644 --- a/unittests/API/APITest.cpp +++ b/unittests/API/APITest.cpp @@ -2833,6 +2833,173 @@ TEST_P(HermesRuntimeTest, IsUint8ArrayTest) { } } +namespace { +/// A property that IPropertyEnumeration::forEachProperty visits, with its name +/// in UTF-16. +struct VisitedProperty { + std::u16string name; + Value value; +}; + +/// Converts a name that forEachProperty passes to UTF-16. +std::u16string nameToUTF16(bool ascii, const void *name, size_t length) { + if (ascii) { + const char *chars = static_cast(name); + return std::u16string(chars, chars + length); + } + return std::u16string(static_cast(name), length); +} + +/// The properties that getProperty returns for each name of getPropertyNames. +std::vector getPropertiesByName( + Runtime &rt, + const Object &obj) { + std::vector properties; + Array names = obj.getPropertyNames(rt); + for (size_t i = 0; i < names.size(rt); ++i) { + String name = names.getValueAtIndex(rt, i).getString(rt); + properties.push_back({name.utf16(rt), obj.getProperty(rt, name)}); + } + return properties; +} +} // namespace + +TEST_P(HermesRuntimeTest, ForEachPropertyMatchesGetProperty) { + auto *enumeration = castInterface(rt.get()); + if (!enumeration) { + // HermesSandboxRuntime doesn't provide the interface. + return; + } + for (const char *source : + {"({title: 1, 7: 2, '\\u00e9t\\u00e9': 3, '\\ud800': 4," + " [Symbol('s')]: 5, empty: null})", + "[10, 20, 30]", + "(function() {" + " var o = Object.create({inherited: 1});" + " o.own = 2;" + " Object.defineProperty(o, 'getter', {" + " get: function() { return 3; }, enumerable: true});" + " Object.defineProperty(o, 'hidden', {value: 4, enumerable: false});" + " return o;" + "})()", + "new Proxy({a: 1, b: 2}, {" + " get: function(target, key) { return key + '!'; }})", + "({})"}) { + Object obj = eval(source).getObject(*rt); + std::vector expected = getPropertiesByName(*rt, obj); + + struct Context { + Runtime &rt; + std::vector visited; + } context{*rt, {}}; + enumeration->forEachProperty( + obj, + &context, + [](void *ctx, + bool ascii, + const void *name, + size_t length, + Value &value) { + auto &c = *static_cast(ctx); + std::u16string utf16 = nameToUTF16(ascii, name, length); + // The callback may use the runtime, which may allocate and move + // strings. The name stays valid during the call. + String::createFromUtf8(c.rt, "allocates"); + c.rt.instrumentation().collectGarbage("forEachProperty"); + EXPECT_EQ(nameToUTF16(ascii, name, length), utf16); + c.visited.push_back({std::move(utf16), std::move(value)}); + }); + + ASSERT_EQ(context.visited.size(), expected.size()) << source; + for (size_t i = 0; i < expected.size(); ++i) { + EXPECT_EQ(context.visited[i].name, expected[i].name) << source; + EXPECT_TRUE(Value::strictEquals( + *rt, context.visited[i].value, expected[i].value)) + << source; + } + } +} + +TEST_P(HermesRuntimeTest, ForEachPropertyWhenTheObjectChanges) { + auto *enumeration = castInterface(rt.get()); + if (!enumeration) { + // HermesSandboxRuntime doesn't provide the interface. + return; + } + Object obj = eval("({a: 1, b: 2, c: 3})").getObject(*rt); + + // While visiting `a`, the callback deletes `b` and adds `d`. `b` is still + // visited, with the value that getProperty returns, and `d` isn't visited. + struct Context { + Runtime &rt; + Object &obj; + std::vector visited; + } context{*rt, obj, {}}; + enumeration->forEachProperty( + obj, + &context, + [](void *ctx, bool ascii, const void *name, size_t length, Value &value) { + auto &c = *static_cast(ctx); + if (c.visited.empty()) { + c.obj.deleteProperty(c.rt, "b"); + c.obj.setProperty(c.rt, "d", 4); + } + c.visited.push_back( + {nameToUTF16(ascii, name, length), std::move(value)}); + }); + + ASSERT_EQ(context.visited.size(), 3); + EXPECT_EQ(context.visited[0].name, u"a"); + EXPECT_EQ(context.visited[0].value.getNumber(), 1); + EXPECT_EQ(context.visited[1].name, u"b"); + EXPECT_TRUE(context.visited[1].value.isUndefined()); + EXPECT_EQ(context.visited[2].name, u"c"); + EXPECT_EQ(context.visited[2].value.getNumber(), 3); +} + +TEST_P(HermesRuntimeTest, ForEachPropertyStopsOnExceptions) { + auto *enumeration = castInterface(rt.get()); + if (!enumeration) { + // HermesSandboxRuntime doesn't provide the interface. + return; + } + auto countCalls = + [](void *ctx, bool, const void *, size_t, Value &) { + ++*static_cast(ctx); + }; + + // A getter that throws stops the enumeration with a JSError. + Object withThrowingGetter = eval( + "({a: 1," + " get b() { throw new Error('getter'); }," + " c: 3})") + .getObject(*rt); + int calls = 0; + EXPECT_THROW( + enumeration->forEachProperty(withThrowingGetter, &calls, countCalls), + JSError); + EXPECT_EQ(calls, 1); + + // An exception from the callback stops the enumeration and propagates. + Object obj = eval("({a: 1, b: 2, c: 3})").getObject(*rt); + calls = 0; + EXPECT_THROW( + enumeration->forEachProperty( + obj, + &calls, + [](void *ctx, bool, const void *, size_t, Value &) { + ++*static_cast(ctx); + throw std::runtime_error("callback"); + }), + std::runtime_error); + EXPECT_EQ(calls, 1); + + // The runtime stays usable. + calls = 0; + enumeration->forEachProperty(obj, &calls, countCalls); + EXPECT_EQ(calls, 3); +} + #ifdef JSI_UNSTABLE class HermesSerializationTest : public HermesRuntimeTest { public: