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: