diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js
index 8184e7553..3864e15c3 100644
--- a/test-app/app/src/main/assets/app/mainpage.js
+++ b/test-app/app/src/main/assets/app/mainpage.js
@@ -42,6 +42,7 @@ require("./tests/testGC");
require("./tests/testsMemoryManagement");
require("./tests/testFieldGetSet");
require("./tests/extendedClassesTests");
+require("./tests/testNativeESClasses");
//require("./tests/extendClassNameTests"); // as tests now run with SBG, this test fails the whole build process
require("./tests/testJniReferenceLeak");
require("./tests/testNativeModules");
diff --git a/test-app/app/src/main/assets/app/tests/testNativeESClasses.js b/test-app/app/src/main/assets/app/tests/testNativeESClasses.js
new file mode 100644
index 000000000..d013be256
--- /dev/null
+++ b/test-app/app/src/main/assets/app/tests/testNativeESClasses.js
@@ -0,0 +1,338 @@
+describe("Tests native ES class extensions (class X extends NativeType)", function () {
+
+ var appClassLoader = com.tns.Runtime.class.getClassLoader();
+
+ // DummyClass.method2(Object) returns "obj=" + obj.toString(), forcing a Java-side virtual
+ // toString dispatch through the generated proxy. (java.lang.String.valueOf is unusable for
+ // this: accessing `java.lang.String.null` in other suites permanently replaces `valueOf` on
+ // the ctor function with the runtime's null-returning valueOf.)
+ function javaToString(obj) {
+ return new com.tns.tests.DummyClass().method2(obj);
+ }
+
+ it("When_extending_a_class_with_es_class_syntax_instances_should_construct_and_dispatch_overrides", function () {
+ class EsButton extends com.tns.tests.Button1 {
+ getIMAGE_ID_PROP() {
+ return "es class override";
+ }
+ }
+
+ var button = new EsButton();
+
+ expect(button instanceof EsButton).toBe(true);
+ expect(button instanceof com.tns.tests.Button1).toBe(true);
+ expect(button.getIMAGE_ID_PROP()).toBe("es class override");
+ // non-overridden base methods still work
+ expect(button.echo("hello")).toBe("hello");
+ });
+
+ it("When_java_calls_a_virtual_method_it_should_dispatch_to_the_es_class_override", function () {
+ class EchoButton extends com.tns.tests.Button1 {
+ echo(s) {
+ return "es:" + s;
+ }
+ }
+
+ var button = new EchoButton();
+
+ // triggerEcho calls this.echo(s) on the Java side - it must route
+ // through the generated proxy back into the ES class method
+ expect(button.triggerEcho("x")).toBe("es:x");
+ });
+
+ it("When_calling_super_method_from_an_es_class_override_it_should_invoke_the_java_implementation", function () {
+ class SuperEchoButton extends com.tns.tests.Button1 {
+ echo(s) {
+ return "es:" + super.echo(s);
+ }
+ }
+
+ var button = new SuperEchoButton();
+
+ expect(button.echo("y")).toBe("es:y");
+ // round trip: Java triggerEcho -> proxy echo -> JS override -> super.echo -> Java echo
+ expect(button.triggerEcho("z")).toBe("es:z");
+ });
+
+ it("When_the_es_class_constructor_passes_arguments_to_super_the_matching_java_constructor_should_be_used", function () {
+ class CtorButton extends com.tns.tests.Button1 {
+ constructor(value) {
+ super(value); // Button1(int) overload
+ this.value = value;
+ }
+ }
+
+ var button = new CtorButton(5);
+
+ expect(button.value).toBe(5);
+ expect(button instanceof com.tns.tests.Button1).toBe(true);
+ });
+
+ it("When_accessing_the_class_property_before_any_instance_exists_the_class_should_be_registered_lazily", function () {
+ class LazyTouch extends java.lang.Object {
+ toString() {
+ return "lazy touch";
+ }
+ }
+
+ // no `new` has happened - static touch must register the proxy class
+ var clazz = LazyTouch.class;
+
+ expect(clazz).not.toBe(null);
+ expect(clazz.getName()).toContain("LazyTouch");
+
+ // the class is fully functional before any JS-side construction:
+ // Java instantiates it through reflection and dispatches to the JS override
+ var created = clazz.newInstance();
+ expect(javaToString(created)).toBe("obj=lazy touch");
+ });
+
+ it("When_passing_the_es_class_to_a_java_method_expecting_a_class_it_should_marshal_to_java_lang_Class", function () {
+ class MarshalledClass extends com.tns.tests.DummyClass {
+ }
+
+ // Class.isAssignableFrom(Class) - MarshalledClass is registered lazily during marshalling
+ expect(com.tns.tests.DummyClass.class.isAssignableFrom(MarshalledClass)).toBe(true);
+ expect(java.lang.Object.class.isAssignableFrom(MarshalledClass)).toBe(true);
+ });
+
+ it("When_passing_the_es_class_where_java_lang_Object_is_expected_it_should_marshal_to_its_java_lang_Class", function () {
+ class ObjectMarshalledClass extends java.lang.Object {
+ }
+
+ var list = new java.util.ArrayList();
+ list.add(ObjectMarshalledClass);
+
+ var stored = list.get(0);
+ expect(stored.getName()).toBe(ObjectMarshalledClass.class.getName());
+ });
+
+ it("When_extending_an_es_class_that_extends_a_native_class_each_level_should_get_its_own_proxy", function () {
+ class LevelOne extends com.tns.tests.Button1 {
+ getIMAGE_ID_PROP() {
+ return "level one";
+ }
+ echo(s) {
+ return "L1:" + s;
+ }
+ }
+
+ class LevelTwo extends LevelOne {
+ getIMAGE_ID_PROP() {
+ return "level two + " + super.getIMAGE_ID_PROP();
+ }
+ }
+
+ var two = new LevelTwo();
+ expect(two instanceof LevelTwo).toBe(true);
+ expect(two instanceof LevelOne).toBe(true);
+ expect(two instanceof com.tns.tests.Button1).toBe(true);
+ expect(two.getIMAGE_ID_PROP()).toBe("level two + level one");
+ // method defined only on the intermediate level must be part of the proxy overrides
+ expect(two.triggerEcho("q")).toBe("L1:q");
+
+ var one = new LevelOne();
+ expect(one.getIMAGE_ID_PROP()).toBe("level one");
+ expect(one instanceof LevelTwo).toBe(false);
+
+ expect(one.getClass().getName()).not.toBe(two.getClass().getName());
+ });
+
+ it("When_constructing_the_es_class_multiple_times_all_instances_should_share_one_proxy_class", function () {
+ class SharedProxyButton extends com.tns.tests.Button1 {
+ }
+
+ var first = new SharedProxyButton();
+ var second = new SharedProxyButton();
+
+ expect(first.getClass().equals(second.getClass())).toBe(true);
+ expect(first.getClass().equals(SharedProxyButton.class)).toBe(true);
+ });
+
+ it("When_reading_base_class_statics_through_the_es_class_they_should_resolve_to_the_java_members", function () {
+ class StaticsButton extends com.tns.tests.Button1 {
+ static jsStatic = 42;
+ static jsStaticMethod() {
+ return "js static";
+ }
+ }
+
+ // Java statics are reachable through the constructor prototype chain
+ expect(StaticsButton.STATIC_IMAGE_ID).toBe("static image id");
+ expect(StaticsButton.SGetStaticImageId()).toBe("static image id");
+
+ // plain JS statics live on the JS constructor and are untouched
+ expect(StaticsButton.jsStatic).toBe(42);
+ expect(StaticsButton.jsStaticMethod()).toBe("js static");
+ });
+
+ it("When_the_es_class_is_registered_its_proxy_should_be_discoverable_through_the_app_class_loader", function () {
+ class DiscoverableEsClass extends java.lang.Object {
+ toString() {
+ return "discoverable es class";
+ }
+ }
+
+ var instance = new DiscoverableEsClass();
+ var className = instance.getClass().getName();
+
+ var found = java.lang.Class.forName(className, false, appClassLoader);
+
+ expect(found.getName()).toBe(className);
+ expect(found.equals(instance.getClass())).toBe(true);
+ expect(found.equals(DiscoverableEsClass.class)).toBe(true);
+ });
+
+ it("When_implementing_an_interface_with_es_class_syntax_java_should_dispatch_to_the_js_methods", function () {
+ var runCount = 0;
+
+ class EsRunnable extends java.lang.Runnable {
+ run() {
+ runCount++;
+ }
+ }
+
+ var runnable = new EsRunnable();
+ expect(runnable instanceof java.lang.Runnable).toBe(true);
+
+ // Thread.run() (not started) invokes target.run() synchronously on the current thread
+ var thread = new java.lang.Thread(runnable);
+ thread.run();
+
+ expect(runCount).toBe(1);
+ });
+
+ it("When_declaring_static_interfaces_the_proxy_should_implement_them", function () {
+ var ran = { value: false };
+
+ class WithInterfaces extends java.lang.Object {
+ static interfaces = [java.lang.Runnable];
+
+ run() {
+ ran.value = true;
+ }
+ }
+
+ var instance = new WithInterfaces();
+
+ expect(instance instanceof java.lang.Runnable).toBe(true);
+
+ var thread = new java.lang.Thread(instance);
+ thread.run();
+
+ expect(ran.value).toBe(true);
+ });
+
+ it("When_getting_the_class_name_it_should_be_stable_and_descriptive", function () {
+ class StableNameClass extends java.lang.Object {
+ }
+
+ var name = StableNameClass.class.getName();
+
+ // runtime generated proxies are named _es_, mirroring the
+ // legacy ____ scheme (the com.tns.gen prefix is only
+ // present on build-time pre-generated bindings)
+ expect(name).toContain("java.lang.Object_es");
+ expect(name).toContain("StableNameClass");
+ // repeated access resolves to the very same class
+ expect(StableNameClass.class.getName()).toBe(name);
+ });
+
+ it("When_passing_an_es_class_instance_to_java_and_reading_it_back_it_should_be_the_same_object", function () {
+ class RoundTripObject extends java.lang.Object {
+ toString() {
+ return "round trip";
+ }
+ }
+
+ var instance = new RoundTripObject();
+ var list = new java.util.ArrayList();
+ list.add(instance);
+
+ var stored = list.get(0);
+ expect(stored.equals(instance)).toBe(true);
+ expect(javaToString(stored)).toBe("obj=round trip");
+ });
+
+ it("When_calling_extend_on_an_es_class_it_should_throw_a_descriptive_error", function () {
+ class NotExtendable extends com.tns.tests.Button1 {
+ }
+
+ expect(function () {
+ NotExtendable.extend({
+ toString: function () {
+ return "should not work";
+ }
+ });
+ }).toThrow();
+ });
+
+ it("When_extending_a_class_created_with_legacy_extend_the_old_behavior_should_be_preserved", function () {
+ var LegacyButton = com.tns.tests.Button1.extend({
+ getIMAGE_ID_PROP: function () {
+ return "legacy override";
+ }
+ });
+
+ class EsOnLegacy extends LegacyButton {
+ }
+
+ // the legacy proxy is used - ES levels above `.extend()`-created classes get no proxy of their own
+ var instance = new EsOnLegacy();
+ expect(instance instanceof com.tns.tests.Button1).toBe(true);
+ expect(instance.getIMAGE_ID_PROP()).toBe("legacy override");
+ });
+
+ it("When_a_plain_js_class_hierarchy_is_used_the_runtime_should_not_interfere", function () {
+ class PlainBase {
+ value() {
+ return 1;
+ }
+ }
+
+ class PlainDerived extends PlainBase {
+ value() {
+ return super.value() + 1;
+ }
+ }
+
+ var plain = new PlainDerived();
+ expect(plain.value()).toBe(2);
+ expect(function () {
+ return plain instanceof java.lang.Object;
+ }).not.toThrow();
+ });
+
+ it("When_the_NativeClass_decorator_is_applied_it_should_be_a_noop", function () {
+ expect(typeof global.NativeClass).toBe("function");
+
+ const DecoratedButton = global.NativeClass(class DecoratedButton extends com.tns.tests.Button1 {
+ getIMAGE_ID_PROP() {
+ return "decorated";
+ }
+ });
+
+ var button = new DecoratedButton();
+ expect(button.getIMAGE_ID_PROP()).toBe("decorated");
+ });
+
+ it("When_anonymous_es_classes_extend_native_types_each_should_get_a_distinct_proxy", function () {
+ var First = class extends java.lang.Object {
+ toString() {
+ return "first anonymous";
+ }
+ };
+ var Second = class extends java.lang.Object {
+ toString() {
+ return "second anonymous";
+ }
+ };
+
+ var firstInstance = new First();
+ var secondInstance = new Second();
+
+ expect(javaToString(firstInstance)).toBe("obj=first anonymous");
+ expect(javaToString(secondInstance)).toBe("obj=second anonymous");
+ expect(firstInstance.getClass().equals(secondInstance.getClass())).toBe(false);
+ });
+});
diff --git a/test-app/app/src/main/assets/internal/ts_helpers.js b/test-app/app/src/main/assets/internal/ts_helpers.js
index d1860dc47..788356b5f 100644
--- a/test-app/app/src/main/assets/internal/ts_helpers.js
+++ b/test-app/app/src/main/assets/internal/ts_helpers.js
@@ -166,6 +166,14 @@
}
}
+ // No-op decorator for plain ES classes extending native types.
+ // The runtime registers such classes lazily (on first construction, static usage or when
+ // passed to native APIs), so the decorator only exists so shared iOS/Android sources and
+ // non-transformed code keep working.
+ function NativeClass(target) {
+ return target;
+ }
+
Object.defineProperty(global, "__native", { value: __native });
Object.defineProperty(global, "__extends", { value: __extends });
Object.defineProperty(global, "__decorate", { value: __decorate });
@@ -174,4 +182,7 @@
global.JavaProxy = JavaProxy;
}
global.Interfaces = Interfaces;
+ if (!global.NativeClass) {
+ global.NativeClass = NativeClass;
+ }
})()
\ No newline at end of file
diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp
index 5ce450fef..78ee14d54 100644
--- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp
+++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp
@@ -163,6 +163,50 @@ jclass CallbackHandlers::ResolveClass(Isolate *isolate, const string &baseClassN
return globalRefToGeneratedClass;
}
+jclass CallbackHandlers::ResolveClass(Isolate *isolate, const string &baseClassName,
+ const string &fullClassName,
+ const vector &methodOverrides,
+ const vector &implementedInterfaces,
+ bool isInterface) {
+ JEnv env;
+ jclass globalRefToGeneratedClass = env.CheckForClassInCache(fullClassName);
+
+ if (globalRefToGeneratedClass == nullptr) {
+ JniLocalRef javaBaseClassName(env.NewStringUTF(baseClassName.c_str()));
+ JniLocalRef javaFullClassName(env.NewStringUTF(fullClassName.c_str()));
+
+ jobjectArray methodOverridesArr = GetJavaStringArray(env, methodOverrides.size());
+ for (size_t i = 0; i < methodOverrides.size(); i++) {
+ JniLocalRef name(env.NewStringUTF(methodOverrides[i].c_str()));
+ env.SetObjectArrayElement(methodOverridesArr, i, name);
+ }
+
+ jobjectArray implementedInterfacesArr = GetJavaStringArray(env, implementedInterfaces.size());
+ for (size_t i = 0; i < implementedInterfaces.size(); i++) {
+ JniLocalRef name(env.NewStringUTF(implementedInterfaces[i].c_str()));
+ env.SetObjectArrayElement(implementedInterfacesArr, i, name);
+ }
+
+ auto runtime = Runtime::GetRuntime(isolate);
+
+ // create or load generated binding (java class)
+ jclass generatedClass = (jclass) env.CallObjectMethod(runtime->GetJavaRuntime(),
+ RESOLVE_CLASS_METHOD_ID,
+ (jstring) javaBaseClassName,
+ (jstring) javaFullClassName,
+ methodOverridesArr,
+ implementedInterfacesArr,
+ isInterface);
+
+ globalRefToGeneratedClass = env.InsertClassIntoCache(fullClassName, generatedClass);
+
+ env.DeleteGlobalRef(methodOverridesArr);
+ env.DeleteGlobalRef(implementedInterfacesArr);
+ }
+
+ return globalRefToGeneratedClass;
+}
+
// Called by ExtendMethodCallback when extending a class
string CallbackHandlers::ResolveClassName(Isolate *isolate, jclass &clazz) {
auto runtime = Runtime::GetRuntime(isolate);
diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.h b/test-app/runtime/src/main/cpp/CallbackHandlers.h
index eddcca93d..75b3f7635 100644
--- a/test-app/runtime/src/main/cpp/CallbackHandlers.h
+++ b/test-app/runtime/src/main/cpp/CallbackHandlers.h
@@ -41,6 +41,18 @@ namespace tns {
const v8::Local &implementationObject,
bool isInterface);
+ /*
+ * ResolveClass variant with explicitly collected method override names and implemented
+ * interface names. Used for plain ES class extensions where the overrides span multiple
+ * prototype levels and are non-enumerable (so the implementationObject-based scan above
+ * cannot see them).
+ */
+ static jclass ResolveClass(v8::Isolate *isolate, const std::string &baseClassName,
+ const std::string &fullClassName,
+ const std::vector &methodOverrides,
+ const std::vector &implementedInterfaces,
+ bool isInterface);
+
static std::string ResolveClassName(v8::Isolate *isolate, jclass &clazz);
static v8::Local
diff --git a/test-app/runtime/src/main/cpp/JsArgConverter.cpp b/test-app/runtime/src/main/cpp/JsArgConverter.cpp
index e5d82f0bd..995f54761 100644
--- a/test-app/runtime/src/main/cpp/JsArgConverter.cpp
+++ b/test-app/runtime/src/main/cpp/JsArgConverter.cpp
@@ -1,5 +1,6 @@
#include "JsArgConverter.h"
#include "ObjectManager.h"
+#include "MetadataNode.h"
#include "JniSignatureParser.h"
#include "JsArgToArrayConverter.h"
#include "ArgConverter.h"
@@ -151,6 +152,22 @@ bool JsArgConverter::ConvertArg(const Local &arg, int index) {
if (!success) {
sprintf(buff, "Cannot convert string to %s at index %d", typeSignature.c_str(), index);
}
+ } else if (arg->IsFunction() &&
+ (typeSignature == "Ljava/lang/Class;" || typeSignature == "Ljava/lang/Object;") &&
+ !MetadataNode::TryResolveClassCtorTypeName(m_isolate, arg.As()).empty()) {
+ // a native type ctor (or a plain ES class extending one - registered lazily here)
+ // passed where Java expects a java.lang.Class. Typed nulls (`SomeClass.null`) and other
+ // functions resolve to an empty name and keep flowing through the object branch below.
+ auto typeName = MetadataNode::TryResolveClassCtorTypeName(m_isolate, arg.As());
+ JEnv env;
+ jclass clazz = env.FindClass(typeName);
+ success = clazz != nullptr;
+ if (success) {
+ // JEnv caches classes as global refs - mark as global so the dtor doesn't delete it
+ SetConvertedObject(index, clazz, true /* isGlobal */);
+ } else {
+ sprintf(buff, "Cannot convert function to %s at index %d", typeSignature.c_str(), index);
+ }
} else if (arg->IsObject()) {
auto context = m_isolate->GetCurrentContext();
auto jsObject = arg->ToObject(context).ToLocalChecked();
diff --git a/test-app/runtime/src/main/cpp/JsArgToArrayConverter.cpp b/test-app/runtime/src/main/cpp/JsArgToArrayConverter.cpp
index e7d424cbb..11d3769e9 100644
--- a/test-app/runtime/src/main/cpp/JsArgToArrayConverter.cpp
+++ b/test-app/runtime/src/main/cpp/JsArgToArrayConverter.cpp
@@ -134,6 +134,20 @@ bool JsArgToArrayConverter::ConvertArg(Local context, const LocalIsFunction() && !MetadataNode::TryResolveClassCtorTypeName(isolate, arg.As()).empty()) {
+ // a native type ctor (or a plain ES class extending one - registered lazily here)
+ // marshals to its java.lang.Class. Typed nulls (`SomeClass.null`) and other functions
+ // resolve to an empty name and keep flowing through the object branch below.
+ auto typeName = MetadataNode::TryResolveClassCtorTypeName(isolate, arg.As());
+ jclass clazz = env.FindClass(typeName);
+ if (clazz != nullptr) {
+ // JEnv caches classes as global refs - mark as global so the dtor doesn't delete it
+ SetConvertedObject(env, index, clazz, true /* isGlobal */);
+ success = true;
+ } else {
+ s << "Cannot marshal JavaScript function at index " << index
+ << " to Java type. Only native type constructors can be marshalled (to java.lang.Class).";
+ }
} else if (arg->IsObject()) {
auto jsObj = arg->ToObject(context).ToLocalChecked();
diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp
index 7d3335fb2..3cac01e24 100644
--- a/test-app/runtime/src/main/cpp/MetadataNode.cpp
+++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp
@@ -11,6 +11,7 @@
#include "Runtime.h"
#include
#include
+#include
#include
#include
#include
@@ -147,7 +148,20 @@ bool MetadataNode::IsNodeTypeInterface() {
}
string MetadataNode::GetTypeMetadataName(Isolate* isolate, Local& value) {
- auto data = GetTypeMetadata(isolate, value.As());
+ if (value.IsEmpty() || !value->IsFunction()) {
+ throw NativeScriptException(string("Cannot resolve native type - the value is not a constructor function."));
+ }
+
+ auto func = value.As();
+ auto data = TryGetTypeMetadata(isolate, func);
+ if (data == nullptr) {
+ // may be a not-yet-registered plain ES class extension
+ data = EnsureExtendedESClass(isolate, func);
+ }
+
+ if (data == nullptr) {
+ throw NativeScriptException(string("Cannot resolve native type - the function does not stand for a native type or an extension of one."));
+ }
return data->name;
}
@@ -258,7 +272,22 @@ void MetadataNode::ClassAccessorGetterCallback(Local property, const Prope
try {
auto thiz = info.This();
auto isolate = info.GetIsolate();
- auto data = GetTypeMetadata(isolate, thiz.As());
+
+ if (thiz.IsEmpty() || !thiz->IsFunction()) {
+ throw NativeScriptException(string("The 'class' property may only be accessed on a native type or an extended class constructor function."));
+ }
+
+ auto func = thiz.As();
+ auto data = TryGetTypeMetadata(isolate, func);
+ if (data == nullptr) {
+ // Plain ES class extension accessed statically before any instance was constructed
+ // (e.g. `MyView.class`) - lazily register it now
+ data = EnsureExtendedESClass(isolate, func);
+ }
+
+ if (data == nullptr) {
+ throw NativeScriptException(string("Cannot resolve java.lang.Class - the function does not stand for a native type or an extension of one."));
+ }
auto value = CallbackHandlers::FindClass(isolate, data->name);
info.GetReturnValue().Set(value);
@@ -1051,6 +1080,310 @@ void MetadataNode::SetTypeMetadata(Isolate* isolate, Local value, Type
V8SetPrivateValue(isolate, value, String::NewFromUtf8(isolate, "typemetadata").ToLocalChecked(), External::New(isolate, data));
}
+MetadataNode::TypeMetadata* MetadataNode::TryGetTypeMetadata(Isolate* isolate, const Local& value) {
+ Local hiddenVal;
+ V8GetPrivateValue(isolate, value, String::NewFromUtf8(isolate, "typemetadata").ToLocalChecked(), hiddenVal);
+
+ if (hiddenVal.IsEmpty() || !hiddenVal->IsExternal()) {
+ return nullptr;
+ }
+
+ return reinterpret_cast(hiddenVal.As()->Value());
+}
+
+std::string MetadataNode::TryResolveClassCtorTypeName(Isolate* isolate, const Local& func) {
+ // A ctor function that had its `.null` accessed doubles as the typed null value for its
+ // type (see NullObjectAccessorGetterCallback - the marker private is set on the ctor and
+ // the ctor itself is returned). Such functions must marshal as typed nulls, not as
+ // java.lang.Class references.
+ Local nullNodeMarker;
+ V8GetPrivateValue(isolate, func, V8StringConstants::GetNullNodeName(isolate), nullNodeMarker);
+ if (!nullNodeMarker.IsEmpty()) {
+ return std::string();
+ }
+
+ auto typeMetadata = TryGetTypeMetadata(isolate, func);
+
+ if (typeMetadata == nullptr) {
+ typeMetadata = EnsureExtendedESClass(isolate, func);
+ }
+
+ return typeMetadata != nullptr ? typeMetadata->name : std::string();
+}
+
+namespace {
+// short deterministic identifier so the same ES class gets the same proxy class name across
+// application launches (keeps the DexFactory on-disk dex cache warm) without depending on
+// line/column numbers that shift with unrelated code edits
+std::string HashESClassId(const std::string& input) {
+ uint32_t hash = 2166136261u;
+ for (char c : input) {
+ hash ^= static_cast(c);
+ hash *= 16777619u;
+ }
+
+ char buff[9];
+ snprintf(buff, sizeof(buff), "%08x", hash);
+ return std::string(buff);
+}
+
+std::string SanitizeESClassNamePart(const std::string& name) {
+ std::string result;
+ result.reserve(name.size());
+ for (char c : name) {
+ bool isValid = isalpha(c) || isdigit(c) || c == '_';
+ result += isValid ? c : '_';
+ }
+ return result;
+}
+
+// True only for genuine `class` syntax constructors. Function source text is the reliable
+// discriminator: per spec, Function.prototype.toString for a class constructor reproduces the
+// `class` declaration/expression source (possibly behind leading comments/whitespace, which V8
+// does not emit for the class case - the text starts with "class").
+bool IsESClassConstructor(v8::Isolate* isolate, const v8::Local& func) {
+ auto context = isolate->GetCurrentContext();
+ v8::Local sourceText;
+ if (!func->FunctionProtoToString(context).ToLocal(&sourceText)) {
+ return false;
+ }
+
+ auto source = tns::ArgConverter::ConvertToString(sourceText);
+ return source.compare(0, 5, "class") == 0;
+}
+} // namespace
+
+MetadataNode::TypeMetadata* MetadataNode::EnsureExtendedESClass(Isolate* isolate, Local ctorFunc) {
+ // Already registered - a native class ctor, a legacy `.extend()`-created ctor or an
+ // ES class ctor registered by a previous call
+ auto existingMetadata = TryGetTypeMetadata(isolate, ctorFunc);
+ if (existingMetadata != nullptr) {
+ return existingMetadata;
+ }
+
+ auto context = isolate->GetCurrentContext();
+
+ // Only genuine `class` syntax constructors participate in lazy ES registration. Downleveled
+ // ES5 "classes" (TypeScript ES5 output, the JavaProxy decorator target, ts_helpers __extends
+ // children) have the same shape - an untagged function whose prototype chain reaches a native
+ // ctor - but must keep flowing through the legacy `.extend()` pipeline.
+ if (!IsESClassConstructor(isolate, ctorFunc)) {
+ return nullptr;
+ }
+
+ // Walk the constructor prototype chain (mirrors the `class X extends Y` chain) and collect
+ // every plain (unregistered) ES constructor level until we reach a constructor holding type
+ // metadata. ES-registered ancestors are flattened into this registration; legacy
+ // `.extend()`-created ancestors are not supported and make this function bail out so callers
+ // preserve their old behavior.
+ std::vector> chainCtors;
+ Local current = ctorFunc;
+ TypeMetadata* baseTypeMetadata = nullptr;
+ while (true) {
+ chainCtors.push_back(current);
+
+ Local parentValue = current->GetPrototype();
+ if (parentValue.IsEmpty() || !parentValue->IsObject() || !parentValue->IsFunction()) {
+ // no native type in the chain - a plain JS class, leave it alone
+ return nullptr;
+ }
+
+ auto parent = parentValue.As();
+ auto parentMetadata = TryGetTypeMetadata(isolate, parent);
+ if (parentMetadata == nullptr) {
+ current = parent;
+ continue;
+ }
+
+ if (parentMetadata->isESDerived) {
+ // Flatten: the parent's registered proxy class sits directly under the pure native
+ // base, so keep walking (collecting the parent's prototype for scanning) until we
+ // reach it
+ current = parent;
+ continue;
+ }
+
+ auto cachedData = GetCachedExtendedClassData(isolate, parentMetadata->name);
+ if (cachedData.extendedCtorFunction != nullptr) {
+ // legacy `.extend()`-created ancestor - not supported for ES class chaining
+ return nullptr;
+ }
+
+ baseTypeMetadata = parentMetadata;
+ break;
+ }
+
+ string baseClassName = baseTypeMetadata->name;
+ auto node = GetOrCreate(baseClassName);
+ if (node == nullptr) {
+ return nullptr;
+ }
+
+ uint8_t nodeType = s_metadataReader.GetNodeType(node->m_treeNode);
+ bool isInterface = s_metadataReader.IsNodeTypeInterface(nodeType);
+
+ // Collect overridden method names level by level, most-derived first, so JS shadowing
+ // semantics carry over. ES class methods are non-enumerable, so unlike the legacy
+ // implementation-object scan we must use ALL_PROPERTIES.
+ std::vector methodOverrides;
+ std::vector implementedInterfaces;
+ robin_hood::unordered_set visitedNames;
+ std::string nativeClassName;
+
+ auto prototypeKey = V8StringConstants::GetPrototype(isolate);
+ auto interfacesKey = ArgConverter::ConvertToV8String(isolate, "interfaces");
+ auto nativeClassNameKey = ArgConverter::ConvertToV8String(isolate, "nativeClassName");
+ auto propertyFilter = static_cast(PropertyFilter::ALL_PROPERTIES | PropertyFilter::SKIP_SYMBOLS);
+
+ for (auto& levelCtor : chainCtors) {
+ Local protoValue;
+ if (!levelCtor->Get(context, prototypeKey).ToLocal(&protoValue) || protoValue.IsEmpty() || !protoValue->IsObject()) {
+ continue;
+ }
+ auto levelPrototype = protoValue.As