From 5c6bc6982ea0c65d352915c89dc54963a59262bc Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 9 Jul 2026 13:39:15 -0700 Subject: [PATCH 1/6] feat: support native ES classes with lazy registration, including static usage before construction Plain ES classes extending native types (class JSClass extends NSObject {}) now register their Objective-C subclass lazily on first native use, without requiring the @NativeClass decorator or ES5 downleveling. Registration is triggered not only by construction (new/new.target) but by any static touch: JSClass.alloc().init(), JSClass.new(), inherited static methods and properties, and passing JSClass directly to native APIs expecting Class or id arguments. A global no-op NativeClass keeps existing decorated code working unchanged. --- NativeScript/runtime/ClassBuilder.h | 31 +- NativeScript/runtime/ClassBuilder.mm | 385 +++++++++++++----- NativeScript/runtime/DataWrapper.h | 9 +- NativeScript/runtime/InlineFunctions.cpp | 4 +- NativeScript/runtime/Interop.mm | 18 + NativeScript/runtime/MetadataBuilder.mm | 61 ++- NativeScript/runtime/js/inline-functions.js | 13 + .../app/tests/Inheritance/ESClassTests.js | 287 +++++++++++++ TestRunner/app/tests/index.js | 1 + 9 files changed, 686 insertions(+), 123 deletions(-) create mode 100644 TestRunner/app/tests/Inheritance/ESClassTests.js diff --git a/NativeScript/runtime/ClassBuilder.h b/NativeScript/runtime/ClassBuilder.h index 8ab4a9f6..fa56764d 100644 --- a/NativeScript/runtime/ClassBuilder.h +++ b/NativeScript/runtime/ClassBuilder.h @@ -1,6 +1,9 @@ #ifndef ClassBuilder_h #define ClassBuilder_h +#include +#include + #include "Common.h" #include "Metadata.h" @@ -38,6 +41,22 @@ class ClassBuilder { static std::string GetTypeEncoding(const TypeEncoding* typeEncoding, int argsCount); + // Lazily registers an Objective-C subclass for a plain ES + // `class X extends NativeBase {}` constructor function. Returns the + // registered class, or nil when ctorFunc is not part of a native inheritance + // chain (or the chain goes through a legacy `.extend()`-created class). + // Idempotent: subsequent calls return the cached class from the ctor's + // ObjCClassWrapper. + static Class EnsureExtendedClass(v8::Local context, + v8::Local ctorFunc); + + // Resolves the Objective-C class that should be instantiated for a construct + // call, honoring `new.target` so that plain ES subclasses of native classes + // get their own registered class. + static Class ResolveConstructedClass(v8::Local context, + v8::Local newTarget, + Class fallback); + private: static std::atomic classNameCounter_; @@ -47,11 +66,13 @@ class ClassBuilder { static void ExtendedClassConstructorCallback( const v8::FunctionCallbackInfo& info); - static void ExposeDynamicMethods(v8::Local context, - Class extendedClass, - v8::Local exposedMethods, - v8::Local exposedProtocols, - v8::Local implementationObject); + static void SwizzleRetainRelease(v8::Isolate* isolate, Class extendedClass); + static void ExposeDynamicMethods( + v8::Local context, Class extendedClass, + v8::Local exposedMethods, + v8::Local exposedProtocols, + v8::Local implementationObject, + std::unordered_set* visitedNames = nullptr); static void ExposeDynamicMembers(v8::Local context, Class extendedClass, v8::Local implementationObject, diff --git a/NativeScript/runtime/ClassBuilder.mm b/NativeScript/runtime/ClassBuilder.mm index 012f1fdc..fa12f5f4 100644 --- a/NativeScript/runtime/ClassBuilder.mm +++ b/NativeScript/runtime/ClassBuilder.mm @@ -163,7 +163,7 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { try { CacheItem* item = static_cast( info.Data().As()->Value(v8::kExternalPointerTypeTagDefault)); - Class klass = item->data_; + Class klass = ClassBuilder::ResolveConstructedClass(context, info.NewTarget(), item->data_); ArgConverter::ConstructObject(context, info, klass); } catch (NativeScriptException& ex) { @@ -302,108 +302,7 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { class_addMethod(object_getClass(extendedClass), @selector(initialize), newInitialize, "v@:"); - /// We swizzle the retain and release methods for the following reason: - /// When we instantiate a native class via a JavaScript call we add it to the object - /// instances map thus incrementing the retainCount by 1. Then, when the native object is - /// referenced somewhere else its count will become more than 1. Since we want to keep the - /// corresponding JavaScript object alive even if it is not used anywhere, we call GcProtect - /// on it. Whenever the native object is released so that its retainCount is 1 (the object - /// instances map), we unprotect the corresponding JavaScript object in order to make both - /// of them destroyable/GC-able. When the JavaScript object is GC-ed we release the native - /// counterpart as well. - void (*retain)(id, SEL) = - (void (*)(id, SEL))FindNotOverridenMethod(extendedClass, @selector(retain)); - IMP newRetain = imp_implementationWithBlock(^(id self) { - if (!isolateWrapper.IsValid()) { - return retain(self, @selector(retain)); - } - if ([self retainCount] == 1) { - auto runtime = Runtime::GetRuntime(isolate); - auto runtimeLoop = runtime->RuntimeLoop(); - void* weakSelf = (__bridge void*)self; - auto gcProtect = [isolateWrapper, weakSelf, isolate]() { - auto innerCache = isolateWrapper.GetCache(); - auto it = innerCache->Instances.find((id)weakSelf); - if (it != innerCache->Instances.end()) { - v8::Locker locker(isolate); - Isolate::Scope isolate_scope(isolate); - HandleScope handle_scope(isolate); - Local value = it->second->Get(isolate); - BaseDataWrapper* wrapper = tns::GetValue(isolate, value); - if (wrapper != nullptr && wrapper->Type() == WrapperType::ObjCObject) { - ObjCDataWrapper* objcWrapper = static_cast(wrapper); - objcWrapper->GcProtect(); - } - } - }; - if (CFRunLoopGetCurrent() != runtimeLoop) { - // bare entry: the closure does its own Locker ceremony, exactly - // like the performed block it replaces - runtime->GetEventLoop()->PostInternalBare(gcProtect); - } else { - gcProtect(); - } - } - - return retain(self, @selector(retain)); - }); - class_addMethod(extendedClass, @selector(retain), newRetain, "@@:"); - - void (*release)(id, SEL) = - (void (*)(id, SEL))FindNotOverridenMethod(extendedClass, @selector(release)); - IMP newRelease = imp_implementationWithBlock(^(id self) { - if (!isolateWrapper.IsValid()) { - release(self, @selector(release)); - return; - } - - if ([self retainCount] == 2) { - void* weakSelf = (__bridge void*)self; - auto gcUnprotect = [isolateWrapper, weakSelf, isolate]() { - auto innerCache = isolateWrapper.GetCache(); - auto it = innerCache->Instances.find((id)weakSelf); - if (it != innerCache->Instances.end()) { - v8::Locker locker(isolate); - Isolate::Scope isolate_scope(isolate); - HandleScope handle_scope(isolate); - if (it->second != nullptr) { - Local value = it->second->Get(isolate); - BaseDataWrapper* wrapper = tns::GetValue(isolate, value); - if (wrapper != nullptr && wrapper->Type() == WrapperType::ObjCObject) { - ObjCDataWrapper* objcWrapper = static_cast(wrapper); - objcWrapper->GcUnprotect(); - } - } - } - }; - auto runtime = Runtime::GetRuntime(isolate); - auto runtimeLoop = runtime->RuntimeLoop(); - if (CFRunLoopGetCurrent() != runtimeLoop) { - // bare entry: the closure does its own Locker ceremony, exactly - // like the performed block it replaces - runtime->GetEventLoop()->PostInternalBare(gcUnprotect); - } else { - auto innerCache = isolateWrapper.GetCache(); - auto it = innerCache->Instances.find(self); - if (it != innerCache->Instances.end()) { - v8::Locker locker(isolate); - Isolate::Scope isolate_scope(isolate); - HandleScope handle_scope(isolate); - if (it->second != nullptr) { - Local value = it->second->Get(isolate); - BaseDataWrapper* wrapper = tns::GetValue(isolate, value); - if (wrapper != nullptr && wrapper->Type() == WrapperType::ObjCObject) { - ObjCDataWrapper* objcWrapper = static_cast(wrapper); - objcWrapper->GcUnprotect(); - } - } - } - } - } - - release(self, @selector(release)); - }); - class_addMethod(extendedClass, @selector(release), newRelease, "v@:"); + ClassBuilder::SwizzleRetainRelease(isolate, extendedClass); info.GetReturnValue().SetUndefined(); }).ToLocalChecked(); @@ -415,6 +314,252 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { tns::Assert(success, isolate); } +void ClassBuilder::SwizzleRetainRelease(Isolate* isolate, Class extendedClass) { + IsolateWrapper isolateWrapper(isolate); + + /// We swizzle the retain and release methods for the following reason: + /// When we instantiate a native class via a JavaScript call we add it to the object + /// instances map thus incrementing the retainCount by 1. Then, when the native object is + /// referenced somewhere else its count will become more than 1. Since we want to keep the + /// corresponding JavaScript object alive even if it is not used anywhere, we call GcProtect + /// on it. Whenever the native object is released so that its retainCount is 1 (the object + /// instances map), we unprotect the corresponding JavaScript object in order to make both + /// of them destroyable/GC-able. When the JavaScript object is GC-ed we release the native + /// counterpart as well. + void (*retain)(id, SEL) = + (void (*)(id, SEL))FindNotOverridenMethod(extendedClass, @selector(retain)); + IMP newRetain = imp_implementationWithBlock(^(id self) { + if (!isolateWrapper.IsValid()) { + return retain(self, @selector(retain)); + } + if ([self retainCount] == 1) { + auto runtime = Runtime::GetRuntime(isolate); + auto runtimeLoop = runtime->RuntimeLoop(); + void* weakSelf = (__bridge void*)self; + auto gcProtect = [isolateWrapper, weakSelf, isolate]() { + auto innerCache = isolateWrapper.GetCache(); + auto it = innerCache->Instances.find((id)weakSelf); + if (it != innerCache->Instances.end()) { + v8::Locker locker(isolate); + Isolate::Scope isolate_scope(isolate); + HandleScope handle_scope(isolate); + Local value = it->second->Get(isolate); + BaseDataWrapper* wrapper = tns::GetValue(isolate, value); + if (wrapper != nullptr && wrapper->Type() == WrapperType::ObjCObject) { + ObjCDataWrapper* objcWrapper = static_cast(wrapper); + objcWrapper->GcProtect(); + } + } + }; + if (CFRunLoopGetCurrent() != runtimeLoop) { + // bare entry: the closure does its own Locker ceremony, exactly + // like the performed block it replaces + runtime->GetEventLoop()->PostInternalBare(gcProtect); + } else { + gcProtect(); + } + } + + return retain(self, @selector(retain)); + }); + class_addMethod(extendedClass, @selector(retain), newRetain, "@@:"); + + void (*release)(id, SEL) = + (void (*)(id, SEL))FindNotOverridenMethod(extendedClass, @selector(release)); + IMP newRelease = imp_implementationWithBlock(^(id self) { + if (!isolateWrapper.IsValid()) { + release(self, @selector(release)); + return; + } + + if ([self retainCount] == 2) { + void* weakSelf = (__bridge void*)self; + auto gcUnprotect = [isolateWrapper, weakSelf, isolate]() { + auto innerCache = isolateWrapper.GetCache(); + auto it = innerCache->Instances.find((id)weakSelf); + if (it != innerCache->Instances.end()) { + v8::Locker locker(isolate); + Isolate::Scope isolate_scope(isolate); + HandleScope handle_scope(isolate); + if (it->second != nullptr) { + Local value = it->second->Get(isolate); + BaseDataWrapper* wrapper = tns::GetValue(isolate, value); + if (wrapper != nullptr && wrapper->Type() == WrapperType::ObjCObject) { + ObjCDataWrapper* objcWrapper = static_cast(wrapper); + objcWrapper->GcUnprotect(); + } + } + } + }; + auto runtime = Runtime::GetRuntime(isolate); + auto runtimeLoop = runtime->RuntimeLoop(); + if (CFRunLoopGetCurrent() != runtimeLoop) { + // bare entry: the closure does its own Locker ceremony, exactly + // like the performed block it replaces + runtime->GetEventLoop()->PostInternalBare(gcUnprotect); + } else { + auto innerCache = isolateWrapper.GetCache(); + auto it = innerCache->Instances.find(self); + if (it != innerCache->Instances.end()) { + v8::Locker locker(isolate); + Isolate::Scope isolate_scope(isolate); + HandleScope handle_scope(isolate); + if (it->second != nullptr) { + Local value = it->second->Get(isolate); + BaseDataWrapper* wrapper = tns::GetValue(isolate, value); + if (wrapper != nullptr && wrapper->Type() == WrapperType::ObjCObject) { + ObjCDataWrapper* objcWrapper = static_cast(wrapper); + objcWrapper->GcUnprotect(); + } + } + } + } + } + + release(self, @selector(release)); + }); + class_addMethod(extendedClass, @selector(release), newRelease, "v@:"); +} + +Class ClassBuilder::EnsureExtendedClass(Local context, Local ctorFunc) { + Isolate* isolate = context->GetIsolate(); + + // Already registered (or a native/extended constructor that carries a class wrapper) + BaseDataWrapper* existingWrapper = tns::GetValue(isolate, ctorFunc); + if (existingWrapper != nullptr) { + if (existingWrapper->Type() == WrapperType::ObjCClass) { + return static_cast(existingWrapper)->Klass(); + } + return nil; + } + + // 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 an + // ObjCClassWrapper. ES-registered ancestors are flattened into this registration; legacy + // `.extend()`-created ancestors are not supported (mirrors the historic restriction) and + // make this function bail out so callers preserve their old behavior. + std::vector> chainCtors; + Local current = ctorFunc; + Class baseClass = nil; + while (true) { + chainCtors.push_back(current); + + Local parentValue = current->GetPrototype(); + if (parentValue.IsEmpty() || !parentValue->IsObject() || !parentValue->IsFunction()) { + return nil; + } + + Local parent = parentValue.As(); + BaseDataWrapper* parentWrapper = tns::GetValue(isolate, parent); + if (parentWrapper == nullptr) { + current = parent; + continue; + } + + if (parentWrapper->Type() != WrapperType::ObjCClass) { + return nil; + } + + ObjCClassWrapper* parentClassWrapper = static_cast(parentWrapper); + if (!parentClassWrapper->ExtendedClass()) { + baseClass = parentClassWrapper->Klass(); + break; + } + + if (parentClassWrapper->ESDerivedClass()) { + // Flatten: the parent's registered 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; + } + + // Legacy `.extend()`-created base - not supported for ES class chaining. + return nil; + } + + if (baseClass == nil) { + return nil; + } + + auto cache = Caches::Get(isolate); + auto isolateId = cache->getIsolateId(); + + std::string baseClassName = class_getName(baseClass); + std::string className = tns::ToString(isolate, ctorFunc->GetName()); + + ScopeClassNameToIsolate(className, isolateId); + Class extendedClass = ClassBuilder::GetExtendedClass(baseClassName, className, isolateId); + tns::Assert(extendedClass != nil, isolate); + class_addProtocol(extendedClass, @protocol(TNSDerivedClass)); + class_addProtocol(object_getClass(extendedClass), @protocol(TNSDerivedClass)); + + // Expose members level by level, most-derived first, so JS shadowing semantics carry over to + // the installed Objective-C implementations. Statics like ObjCProtocols/ObjCExposedMethods are + // read through the constructor (inheriting through the static chain like class statics do). + std::unordered_set visitedNames; + for (Local levelCtor : chainCtors) { + Local prototypeValue; + bool success = levelCtor->Get(context, tns::ToV8String(isolate, "prototype")) + .ToLocal(&prototypeValue); + tns::Assert(success && !prototypeValue.IsEmpty() && prototypeValue->IsObject(), isolate); + Local implementationObject = prototypeValue.As(); + + Local exposedMethods; + success = levelCtor->Get(context, tns::ToV8String(isolate, "ObjCExposedMethods")) + .ToLocal(&exposedMethods); + tns::Assert(success, isolate); + + Local exposedProtocols; + success = levelCtor->Get(context, tns::ToV8String(isolate, "ObjCProtocols")) + .ToLocal(&exposedProtocols); + tns::Assert(success, isolate); + + ClassBuilder::ExposeDynamicMethods(context, extendedClass, exposedMethods, exposedProtocols, + implementationObject, &visitedNames); + } + + tns::SetValue(isolate, ctorFunc, new ObjCClassWrapper(extendedClass, true, true)); + + std::string extendedClassName = class_getName(extendedClass); + + auto extendedPersistent = std::make_unique>(isolate, ctorFunc); + extendedPersistent->SetWrapperClassId(Constants::ClassTypes::DataWrapper); + cache->CtorFuncs.emplace(extendedClassName, std::move(extendedPersistent)); + + Local ctorPrototypeValue; + bool success = ctorFunc->Get(context, tns::ToV8String(isolate, "prototype")) + .ToLocal(&ctorPrototypeValue); + tns::Assert(success && !ctorPrototypeValue.IsEmpty() && ctorPrototypeValue->IsObject(), isolate); + cache->ClassPrototypes.emplace(extendedClassName, + std::make_unique>( + isolate, ctorPrototypeValue.As())); + + ClassBuilder::SwizzleRetainRelease(isolate, extendedClass); + + return extendedClass; +} + +Class ClassBuilder::ResolveConstructedClass(Local context, Local newTarget, + Class fallback) { + if (newTarget.IsEmpty() || !newTarget->IsFunction()) { + return fallback; + } + + Isolate* isolate = context->GetIsolate(); + Local newTargetFunc = newTarget.As(); + + BaseDataWrapper* wrapper = tns::GetValue(isolate, newTargetFunc); + if (wrapper != nullptr) { + if (wrapper->Type() == WrapperType::ObjCClass) { + return static_cast(wrapper)->Klass(); + } + return fallback; + } + + Class ensured = ClassBuilder::EnsureExtendedClass(context, newTargetFunc); + return ensured != nil ? ensured : fallback; +} + void ClassBuilder::ExposeDynamicMembers(v8::Local context, Class extendedClass, Local implementationObject, Local nativeSignature) { @@ -589,8 +734,9 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { void ClassBuilder::ExposeDynamicMethods(Local context, Class extendedClass, Local exposedMethods, Local exposedProtocols, - Local implementationObject) { - Isolate* isolate = v8::Isolate::GetCurrent(); + Local implementationObject, + std::unordered_set* visitedNames) { + Isolate* isolate = context->GetIsolate(); std::vector protocols; if (!exposedProtocols.IsEmpty() && exposedProtocols->IsArray()) { Local protocolsArray = exposedProtocols.As(); @@ -624,6 +770,13 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { bool success = methodNames->Get(context, i).ToLocal(&methodName); tns::Assert(success, isolate); + // When flattening a multi-level ES class chain, skip names already exposed by a more + // derived level (JS shadowing semantics) + if (visitedNames != nullptr && + !visitedNames->insert("exposed:" + tns::ToString(isolate, methodName)).second) { + continue; + } + Local methodSignature; success = exposedMethods.As()->Get(context, methodName).ToLocal(&methodSignature); tns::Assert(success && methodSignature->IsObject(), isolate); @@ -711,7 +864,8 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { implementationObject->Get(context, Symbol::GetIterator(isolate)).ToLocal(&symbolIterator); tns::Assert(success, isolate); - if (!symbolIterator.IsEmpty() && symbolIterator->IsFunction()) { + if (!symbolIterator.IsEmpty() && symbolIterator->IsFunction() && + !class_conformsToProtocol(extendedClass, @protocol(NSFastEnumeration))) { Local symbolIteratorFunc = symbolIterator.As(); class_addProtocol(extendedClass, @protocol(NSFastEnumeration)); @@ -734,7 +888,14 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { isolate); } - tns::Assert(implementationObject->GetOwnPropertyNames(context).ToLocal(&propertyNames), isolate); + // Use ALL_PROPERTIES so that non-enumerable members are picked up too - methods and accessors + // declared with ES class syntax are non-enumerable, unlike the plain object literals passed to + // the legacy `.extend()` API. + PropertyFilter propertyFilter = + static_cast(PropertyFilter::ALL_PROPERTIES | PropertyFilter::SKIP_SYMBOLS); + tns::Assert( + implementationObject->GetOwnPropertyNames(context, propertyFilter).ToLocal(&propertyNames), + isolate); for (uint32_t i = 0; i < propertyNames->Length(); i++) { Local key; bool success = propertyNames->Get(context, i).ToLocal(&key); @@ -745,6 +906,16 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { std::string methodName = tns::ToString(isolate, key); + if (methodName == "constructor") { + continue; + } + + // When flattening a multi-level ES class chain, skip names already handled by a more derived + // level (JS shadowing semantics) + if (visitedNames != nullptr && !visitedNames->insert(methodName).second) { + continue; + } + Local propertyDescriptor; tns::Assert(implementationObject->GetOwnPropertyDescriptor(context, key.As()) .ToLocal(&propertyDescriptor), diff --git a/NativeScript/runtime/DataWrapper.h b/NativeScript/runtime/DataWrapper.h index 55820c70..4b76b8d8 100644 --- a/NativeScript/runtime/DataWrapper.h +++ b/NativeScript/runtime/DataWrapper.h @@ -390,8 +390,8 @@ class ObjCDataWrapper : public BaseDataWrapper { class ObjCClassWrapper : public BaseDataWrapper { public: - ObjCClassWrapper(Class klazz, bool extendedClass = false) - : klass_(klazz), extendedClass_(extendedClass) {} + ObjCClassWrapper(Class klazz, bool extendedClass = false, bool esDerivedClass = false) + : klass_(klazz), extendedClass_(extendedClass), esDerivedClass_(esDerivedClass) {} const WrapperType Type() { return WrapperType::ObjCClass; } @@ -399,9 +399,14 @@ class ObjCClassWrapper : public BaseDataWrapper { bool ExtendedClass() { return this->extendedClass_; } + // true when the class was registered lazily from a plain ES `class X extends NativeBase {}` + // constructor (see ClassBuilder::EnsureExtendedClass) + bool ESDerivedClass() { return this->esDerivedClass_; } + private: Class klass_; bool extendedClass_; + bool esDerivedClass_; }; class ObjCProtocolWrapper : public BaseDataWrapper { diff --git a/NativeScript/runtime/InlineFunctions.cpp b/NativeScript/runtime/InlineFunctions.cpp index 37682cdd..64880766 100644 --- a/NativeScript/runtime/InlineFunctions.cpp +++ b/NativeScript/runtime/InlineFunctions.cpp @@ -21,8 +21,8 @@ bool InlineFunctions::IsGlobalFunction(const std::string& name) { return name == "CGPointMake" || name == "CGRectMake" || name == "CGSizeMake" || name == "UIEdgeInsetsMake" || name == "NSMakeRange" || name == "__decorate" || name == "__param" || - name == "ObjCClass" || name == "ObjCMethod" || name == "ObjC" || - name == "ObjCParam" || name == "__tsEnum"; + name == "NativeClass" || name == "ObjCClass" || name == "ObjCMethod" || + name == "ObjC" || name == "ObjCParam" || name == "__tsEnum"; } } // namespace tns diff --git a/NativeScript/runtime/Interop.mm b/NativeScript/runtime/Interop.mm index 0edf81ed..74dcb460 100644 --- a/NativeScript/runtime/Interop.mm +++ b/NativeScript/runtime/Interop.mm @@ -4,6 +4,7 @@ #include "ArgConverter.h" #include "ArrayAdapter.h" #include "Caches.h" +#include "ClassBuilder.h" #include "Constants.h" #include "DictionaryAdapter.h" #include "ExtVector.h" @@ -583,6 +584,15 @@ inline bool isBool() { } else if (argHelper.isObject() && typeEncoding->type == BinaryTypeEncodingType::ClassEncoding) { Local obj = arg.As(); BaseDataWrapper* wrapper = tns::GetValue(isolate, obj); + if (wrapper == nullptr && obj->IsFunction()) { + // A plain ES subclass of a native type passed where a Class is expected + // (e.g. `tableView.registerClassForCellReuseIdentifier(JSClass, ...)`) - lazily + // register its derived class first. + Class ensured = ClassBuilder::EnsureExtendedClass(context, obj.As()); + if (ensured != nil) { + wrapper = tns::GetValue(isolate, obj); + } + } tns::Assert(wrapper != nullptr && wrapper->Type() == WrapperType::ObjCClass, isolate); ObjCClassWrapper* classWrapper = static_cast(wrapper); Class clazz = classWrapper->Klass(); @@ -737,6 +747,14 @@ inline bool isBool() { } } else { Local obj = arg.As(); + if (obj->IsFunction()) { + // A plain ES subclass of a native type passed where an `id` is expected - lazily + // register its derived class and marshal the Objective-C Class object. + Class ensured = ClassBuilder::EnsureExtendedClass(context, obj.As()); + if (ensured != nil) { + return ensured; + } + } DictionaryAdapter* adapter = [[DictionaryAdapter alloc] initWithJSObject:obj isolate:isolate]; // CFAutorelease(adapter); return adapter; diff --git a/NativeScript/runtime/MetadataBuilder.mm b/NativeScript/runtime/MetadataBuilder.mm index ce9f6417..b8dfb444 100644 --- a/NativeScript/runtime/MetadataBuilder.mm +++ b/NativeScript/runtime/MetadataBuilder.mm @@ -2,6 +2,7 @@ #include #include "ArgConverter.h" #include "Caches.h" +#include "ClassBuilder.h" #include "Constants.h" #include "Helpers.h" #include "InlineFunctions.h" @@ -720,6 +721,10 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta info.Data().As()->Value(v8::kExternalPointerTypeTagDefault)); Class klass = objc_getClass(item->meta_->name()); + // Plain ES `class X extends NativeType {}` subclasses reach this callback through + // super(); use new.target to lazily register (and construct) the derived class. + klass = ClassBuilder::ResolveConstructedClass(context, info.NewTarget(), klass); + const InterfaceMeta* interfaceMeta = static_cast(item->meta_); ArgConverter::ConstructObject(context, info, klass, interfaceMeta); } catch (NativeScriptException& ex) { @@ -733,20 +738,26 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta try { Local thiz = info.This(); - Class klass; + Local context = isolate->GetCurrentContext(); + Class klass = nil; BaseDataWrapper* wrapper = tns::GetValue(isolate, thiz); if (wrapper != nullptr && wrapper->Type() == WrapperType::ObjCClass) { ObjCClassWrapper* classWrapper = static_cast(wrapper); klass = classWrapper->Klass(); - } else { + } else if (wrapper == nullptr && thiz->IsFunction()) { + // `JSClass.alloc()` where JSClass is a plain ES subclass of a native type that has + // not been constructed yet - lazily register its derived class first. + klass = ClassBuilder::EnsureExtendedClass(context, thiz.As()); + } + + if (klass == nil) { CacheItem* item = static_cast*>( info.Data().As()->Value(v8::kExternalPointerTypeTagDefault)); const InterfaceMeta* meta = item->meta_; klass = objc_getClass(meta->name()); } - Local context = isolate->GetCurrentContext(); ObjCAllocDataWrapper* allocWrapper = new ObjCAllocDataWrapper(klass); Local result = ArgConverter::CreateJsWrapper(context, allocWrapper, Local()); info.GetReturnValue().Set(result); @@ -768,16 +779,25 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta const std::string* className = &item->className_; std::string classWrapperName; + Local context = isolate->GetCurrentContext(); Local thiz = info.This(); if (thiz->IsFunction()) { if (BaseDataWrapper* wrapper = tns::GetValue(isolate, thiz)) { ObjCClassWrapper* classWrapper = static_cast(wrapper); classWrapperName = class_getName(classWrapper->Klass()); className = &classWrapperName; + } else { + // Static call through a plain ES subclass of a native type (inherited static), + // e.g. `JSClass.new()` - lazily register the derived class so the invocation + // dispatches to it. + Class ensured = ClassBuilder::EnsureExtendedClass(context, thiz.As()); + if (ensured != nil) { + classWrapperName = class_getName(ensured); + className = &classWrapperName; + } } } - Local context = isolate->GetCurrentContext(); Local result = instanceMethod ? MetadataBuilder::InvokeMethod(context, item->meta_, info.This(), args, *className, true) @@ -833,6 +853,31 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta true); } +// Resolves the class name a static member access should dispatch to, honoring plain ES +// subclasses of native types used as receivers (e.g. `JSClass.someStaticProperty`). +static std::string ResolveStaticReceiverClassName(Local context, Local receiver, + const std::string& fallback) { + if (receiver.IsEmpty() || !receiver->IsFunction()) { + return fallback; + } + + Isolate* isolate = context->GetIsolate(); + BaseDataWrapper* wrapper = tns::GetValue(isolate, receiver); + if (wrapper != nullptr) { + if (wrapper->Type() == WrapperType::ObjCClass) { + return class_getName(static_cast(wrapper)->Klass()); + } + return fallback; + } + + Class ensured = ClassBuilder::EnsureExtendedClass(context, receiver.As()); + if (ensured != nil) { + return class_getName(ensured); + } + + return fallback; +} + void MetadataBuilder::PropertyNameGetterCallback(const FunctionCallbackInfo& info) { Isolate* isolate = info.GetIsolate(); CacheItem* item = static_cast*>( @@ -845,8 +890,9 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta V8EmptyValueArgs args; Local context = isolate->GetCurrentContext(); + std::string className = ResolveStaticReceiverClassName(context, info.This(), item->className_); Local result = MetadataBuilder::InvokeMethod( - context, item->meta_->getter(), Local(), args, item->className_, false); + context, item->meta_->getter(), Local(), args, className, false); if (!result.IsEmpty()) { info.GetReturnValue().Set(result); } @@ -866,8 +912,9 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta V8SimpleValueArgs args(value); Local context = isolate->GetCurrentContext(); - MetadataBuilder::InvokeMethod(context, item->meta_->setter(), Local(), args, - item->className_, false); + std::string className = ResolveStaticReceiverClassName(context, info.This(), item->className_); + MetadataBuilder::InvokeMethod(context, item->meta_->setter(), Local(), args, className, + false); } Intercepted MetadataBuilder::StructPropertyGetterCallback(Local property, diff --git a/NativeScript/runtime/js/inline-functions.js b/NativeScript/runtime/js/inline-functions.js index ea98b3d8..638a499d 100644 --- a/NativeScript/runtime/js/inline-functions.js +++ b/NativeScript/runtime/js/inline-functions.js @@ -1,5 +1,6 @@ const { ArrayPrototypeConcat, + ArrayPrototypeSlice, FunctionPrototypeApply, ObjectAssign, ObjectDefineProperty, @@ -48,6 +49,18 @@ ObjectAssign(global, { } } }, + NativeClass(arg) { + if (typeof arg === 'function') { + return arg; + } + var options = arg || {}; + return function (target) { + if (options.protocols && options.protocols.length > 0) { + target.ObjCProtocols = (target.ObjCProtocols && target.ObjCProtocols instanceof Array ? ArrayPrototypeConcat(target.ObjCProtocols, options.protocols) : ArrayPrototypeSlice(options.protocols)); + } + return target; + } + }, ObjCMethod() { var name = arguments[0]; var hasName = (name !== undefined && typeof name === "string"); diff --git a/TestRunner/app/tests/Inheritance/ESClassTests.js b/TestRunner/app/tests/Inheritance/ESClassTests.js new file mode 100644 index 00000000..765c0fd7 --- /dev/null +++ b/TestRunner/app/tests/Inheritance/ESClassTests.js @@ -0,0 +1,287 @@ +// Tests for native ES class support: plain `class X extends NativeType {}` without the +// @NativeClass decorator or ES5 downleveling. The Objective-C class is registered lazily by +// the runtime on first use (construction, alloc/new, static dispatch or Class marshalling). +describe(module.id, function () { + afterEach(function () { + TNSClearOutput(); + }); + + it('ESClassLazyRegistration', function () { + class ESLazyObject extends NSObject { + } + + // Defining the class must not register anything with the Objective-C runtime + expect(NSClassFromString('ESLazyObject')).toBeNull(); + + var instance = new ESLazyObject(); + expect(instance instanceof ESLazyObject).toBe(true); + + // First construction registers the class under the ES class name + expect(NSClassFromString('ESLazyObject')).toBe(ESLazyObject); + }); + + it('ESClassSimpleInheritance', function () { + class ESSimpleObject extends TNSDerivedInterface { + } + + var object = new ESSimpleObject(); + expect(object.constructor).toBe(ESSimpleObject); + expect(object instanceof ESSimpleObject).toBe(true); + expect(object instanceof TNSDerivedInterface).toBe(true); + expect(object instanceof NSObject).toBe(true); + expect(object.class()).toBe(ESSimpleObject); + expect(object.superclass).toBe(TNSDerivedInterface); + expect(ESSimpleObject.class()).toBe(ESSimpleObject); + expect(ESSimpleObject.superclass()).toBe(TNSDerivedInterface); + expect(NSStringFromClass(ESSimpleObject)).toBe('ESSimpleObject'); + }); + + it('ESClassConstructorLogicAndFields', function () { + class ESConstructorObject extends NSObject { + field = 42; + + constructor() { + super(); + this.initialized = true; + } + } + + var object = new ESConstructorObject(); + // The receiver created by super() must be the native-backed instance, with class + // fields and constructor logic applied to it + expect(object.field).toBe(42); + expect(object.initialized).toBe(true); + expect(object instanceof ESConstructorObject).toBe(true); + expect(object instanceof NSObject).toBe(true); + expect(NSStringFromClass(object.class())).toBe('ESConstructorObject'); + }); + + it('ESClassInstanceMethodsAndSuper', function () { + class ESMethodsObject extends TNSDerivedInterface { + baseMethod() { + TNSLog('js baseMethod called'); + super.baseMethod(); + } + derivedMethod() { + TNSLog('js derivedMethod called'); + super.derivedMethod(); + } + } + + var object = new ESMethodsObject(); + object.baseMethod(); + object.derivedMethod(); + expect(TNSGetOutput()).toBe('js baseMethod called' + + 'instance baseMethod called' + + 'js derivedMethod called' + + 'instance derivedMethod called'); + }); + + it('ESClassPropertyAccessorsAndSuper', function () { + class ESPropertyObject extends TNSDerivedInterface { + get baseProperty() { + TNSLog('js getBaseProperty called'); + return super.baseProperty; + } + set baseProperty(x) { + TNSLog('js setBaseProperty called'); + super.baseProperty = x; + } + } + + var object = new ESPropertyObject(); + object.baseProperty = 0; + UNUSED(object.baseProperty); + expect(TNSGetOutput()).toBe('js setBaseProperty called' + + 'instance setBaseProperty: called' + + 'js getBaseProperty called' + + 'instance baseProperty called'); + }); + + it('ESClassAllocInitBeforeConstruction', function () { + class ESAllocObject extends NSObject { + getAnswer() { + return 42; + } + } + + // alloc().init() without ever calling `new` must register and use the derived class + var object = ESAllocObject.alloc().init(); + expect(object instanceof ESAllocObject).toBe(true); + expect(object.getAnswer()).toBe(42); + expect(NSStringFromClass(object.class())).toBe('ESAllocObject'); + }); + + it('ESClassNewBeforeConstruction', function () { + class ESNewObject extends NSObject { + } + + var object = ESNewObject.new(); + expect(object instanceof ESNewObject).toBe(true); + expect(NSStringFromClass(object.class())).toBe('ESNewObject'); + }); + + it('ESClassStaticMethodDispatch', function () { + class ESStaticMethodObject extends TNSDerivedInterface { + } + + ESStaticMethodObject.baseMethod(); + ESStaticMethodObject.derivedMethod(); + expect(TNSGetOutput()).toBe('static baseMethod called' + + 'static derivedMethod called'); + }); + + it('ESClassStaticPropertyDispatch', function () { + class ESStaticPropertyObject extends TNSDerivedInterface { + } + + ESStaticPropertyObject.baseProperty = 1; + UNUSED(ESStaticPropertyObject.baseProperty); + expect(TNSGetOutput()).toBe('static setBaseProperty: called' + + 'static baseProperty called'); + }); + + it('ESClassPassedAsClassArgument', function () { + class ESClassArgObject extends NSObject { + } + + // Passing the class to a native API before any instance exists must register it + expect(NSStringFromClass(ESClassArgObject)).toBe('ESClassArgObject'); + + var object = new ESClassArgObject(); + expect(object.isKindOfClass(ESClassArgObject)).toBe(true); + expect(object.isMemberOfClass(ESClassArgObject)).toBe(true); + expect(object.isKindOfClass(NSObject)).toBe(true); + }); + + it('ESClassProtocolImplementation', function () { + class ESProtocolObject extends NSObject { + static ObjCProtocols = [TNSBaseProtocol2]; + + baseProtocolMethod1() { + TNSLog('baseProtocolMethod1 called'); + } + baseProtocolMethod2() { + TNSLog('baseProtocolMethod2 called'); + } + } + + var object = ESProtocolObject.alloc().init(); + TNSTestNativeCallbacks.protocolImplementationProtocolInheritance(object); + expect(TNSGetOutput()).toBe('baseProtocolMethod1 called' + + 'baseProtocolMethod2 called'); + }); + + it('ESClassExposedMethods', function () { + class ESExposedObject extends NSObject { + static ObjCExposedMethods = { + 'voidSelector': { returns: interop.types.void }, + 'variadicSelector:x:': { returns: NSObject, params: [NSString, interop.types.int32] } + }; + + voidSelector() { + TNSLog('voidSelector called'); + } + ['variadicSelector:x:'](a, b) { + TNSLog('variadicSelector:' + a + ' x:' + b + ' called'); + return a; + } + } + + var object = new ESExposedObject(); + TNSTestNativeCallbacks.inheritanceVoidSelector(object); + expect(TNSTestNativeCallbacks.inheritanceVariadicSelector(object)).toBe('native'); + expect(TNSGetOutput()).toBe('voidSelector called' + + 'variadicSelector:native x:9 called'); + }); + + it('ESClassDescriptionOverrideFromNative', function () { + class ESDescriptionObject extends NSObject { + get description() { + return 'js description'; + } + } + + // Throws (native assert) if [object description] does not dispatch to the JS getter + TNSTestNativeCallbacks.apiDescriptionOverride(new ESDescriptionObject()); + }); + + it('ESClassMultiLevelInheritance', function () { + class ESLevelA extends TNSDerivedInterface { + baseMethod() { + TNSLog('A baseMethod called'); + super.baseMethod(); + } + derivedMethod() { + TNSLog('A derivedMethod called'); + super.derivedMethod(); + } + } + + class ESLevelB extends ESLevelA { + baseMethod() { + TNSLog('B baseMethod called'); + super.baseMethod(); + } + } + + var b = new ESLevelB(); + expect(b instanceof ESLevelB).toBe(true); + expect(b instanceof ESLevelA).toBe(true); + expect(b instanceof TNSDerivedInterface).toBe(true); + + b.baseMethod(); + b.derivedMethod(); + expect(TNSGetOutput()).toBe('B baseMethod called' + + 'A baseMethod called' + + 'instance baseMethod called' + + 'A derivedMethod called' + + 'instance derivedMethod called'); + TNSClearOutput(); + + // The intermediate class works standalone too, with its own registration + var a = new ESLevelA(); + expect(a instanceof ESLevelA).toBe(true); + expect(a instanceof ESLevelB).toBe(false); + a.baseMethod(); + expect(TNSGetOutput()).toBe('A baseMethod called' + + 'instance baseMethod called'); + }); + + it('ESClassPlainJsSubclassUnaffected', function () { + class PlainBase { + } + class PlainDerived extends PlainBase { + } + + // Classes with no native type in their prototype chain stay plain JS + var object = new PlainDerived(); + expect(object instanceof PlainDerived).toBe(true); + expect(object instanceof PlainBase).toBe(true); + }); + + it('NativeClassGlobalDecoratorNoop', function () { + expect(typeof global.NativeClass).toBe('function'); + + const ESDecoratedPlain = NativeClass(class ESDecoratedPlainObject extends NSObject { + }); + var instance = new ESDecoratedPlain(); + expect(instance instanceof ESDecoratedPlain).toBe(true); + + const ESDecoratedProtocols = NativeClass({ protocols: [TNSBaseProtocol2] })( + class ESDecoratedProtocolsObject extends NSObject { + baseProtocolMethod1() { + TNSLog('baseProtocolMethod1 called'); + } + baseProtocolMethod2() { + TNSLog('baseProtocolMethod2 called'); + } + } + ); + + var object = new ESDecoratedProtocols(); + TNSTestNativeCallbacks.protocolImplementationProtocolInheritance(object); + expect(TNSGetOutput()).toBe('baseProtocolMethod1 called' + + 'baseProtocolMethod2 called'); + }); +}); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index a500d153..92e3f86d 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -119,6 +119,7 @@ require("./Marshalling/ProtocolTests"); require("./Inheritance/InheritanceTests"); require("./Inheritance/ProtocolImplementationTests"); require("./Inheritance/TypeScriptTests"); +require("./Inheritance/ESClassTests"); // require("./MethodCallsTests"); require("./StaleWrapperCacheTests"); From 507c1547c5e91f049995e538bf51a2b5cb2ad5a6 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 9 Jul 2026 14:31:08 -0700 Subject: [PATCH 2/6] test: lock down init cases of es classes --- .../app/tests/Inheritance/ESClassTests.js | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/TestRunner/app/tests/Inheritance/ESClassTests.js b/TestRunner/app/tests/Inheritance/ESClassTests.js index 765c0fd7..f0ced74c 100644 --- a/TestRunner/app/tests/Inheritance/ESClassTests.js +++ b/TestRunner/app/tests/Inheritance/ESClassTests.js @@ -56,6 +56,45 @@ describe(module.id, function () { expect(NSStringFromClass(object.class())).toBe('ESConstructorObject'); }); + it('ESClassSuperArgsSelectInitializer', function () { + class ESCtorArgsObject extends TNSCInterface { + constructor(name, x) { + // Arguments passed to super(...) drive native initializer resolution; + // arguments passed to `new` are only seen by the JS constructor. + super(x); + this.name = name; + } + } + + var object = new ESCtorArgsObject('first', 7); + expect(object instanceof ESCtorArgsObject).toBe(true); + expect(object.name).toBe('first'); + expect(TNSGetOutput()).toBe('initWithPrimitive:7 called'); + TNSClearOutput(); + + class ESCtorTwoArgsObject extends TNSCInterface { + constructor(a, b) { + super(a, b); + } + } + + var object2 = new ESCtorTwoArgsObject(5, 10); + expect(object2 instanceof ESCtorTwoArgsObject).toBe(true); + expect(TNSGetOutput()).toBe('initWithInt:andInt: 5 10 called'); + TNSClearOutput(); + + // super() with no arguments falls back to plain [[Class alloc] init] + class ESCtorNoArgsObject extends TNSCInterface { + constructor() { + super(); + } + } + + var object3 = new ESCtorNoArgsObject(); + expect(object3 instanceof ESCtorNoArgsObject).toBe(true); + expect(TNSGetOutput()).toBe('init called'); + }); + it('ESClassInstanceMethodsAndSuper', function () { class ESMethodsObject extends TNSDerivedInterface { baseMethod() { @@ -112,6 +151,34 @@ describe(module.id, function () { expect(NSStringFromClass(object.class())).toBe('ESAllocObject'); }); + it('ESClassAllocInitDoesNotRunJsConstructor', function () { + var constructorRuns = 0; + + class ESAllocNoCtorObject extends NSObject { + field = 42; + + constructor() { + super(); + constructorRuns++; + this.initializedFromJs = true; + } + } + + // alloc().init() is purely native initialization: the JS constructor body and + // class field initializers only run through `new`, never through alloc/init. + var allocated = ESAllocNoCtorObject.alloc().init(); + expect(constructorRuns).toBe(0); + expect(allocated.field).toBe(undefined); + expect(allocated.initializedFromJs).toBe(undefined); + expect(allocated instanceof ESAllocNoCtorObject).toBe(true); + expect(NSStringFromClass(allocated.class())).toBe('ESAllocNoCtorObject'); + + var constructed = new ESAllocNoCtorObject(); + expect(constructorRuns).toBe(1); + expect(constructed.field).toBe(42); + expect(constructed.initializedFromJs).toBe(true); + }); + it('ESClassNewBeforeConstruction', function () { class ESNewObject extends NSObject { } From 96fb1205a786a4ca2441a2e347a472262a93da58 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 14 Aug 2026 13:39:39 -0700 Subject: [PATCH 3/6] feat: NativeClass API with consolidated behavior --- NativeScript/runtime/ClassBuilder.mm | 9 ++++- NativeScript/runtime/js/inline-functions.js | 35 +++++++++++++++--- types/index.d.ts | 11 +++--- types/ns-nativeclass.d.ts | 41 +++++++++++++++++++++ 4 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 types/ns-nativeclass.d.ts diff --git a/NativeScript/runtime/ClassBuilder.mm b/NativeScript/runtime/ClassBuilder.mm index fa12f5f4..3c87e1a0 100644 --- a/NativeScript/runtime/ClassBuilder.mm +++ b/NativeScript/runtime/ClassBuilder.mm @@ -485,7 +485,14 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { auto isolateId = cache->getIsolateId(); std::string baseClassName = class_getName(baseClass); - std::string className = tns::ToString(isolate, ctorFunc->GetName()); + std::string className; + Local explicitName; + if (ctorFunc->Get(context, tns::ToV8String(isolate, "ObjCClassName")).ToLocal(&explicitName) && + !explicitName.IsEmpty() && explicitName->IsString()) { + className = tns::ToString(isolate, explicitName); + } else { + className = tns::ToString(isolate, ctorFunc->GetName()); + } ScopeClassNameToIsolate(className, isolateId); Class extendedClass = ClassBuilder::GetExtendedClass(baseClassName, className, isolateId); diff --git a/NativeScript/runtime/js/inline-functions.js b/NativeScript/runtime/js/inline-functions.js index 638a499d..cdd4fe3b 100644 --- a/NativeScript/runtime/js/inline-functions.js +++ b/NativeScript/runtime/js/inline-functions.js @@ -8,6 +8,34 @@ const { ObjectKeys, } = primordials; +function applyNativeClassOptions(target, options) { + var ios = options && options.ios; + var protocols = (ios && ios.protocols) || (options && options.protocols); + var methods = (ios && ios.methods) || (options && options.methods); + var name = ios && ios.name; + + if (protocols && protocols.length > 0) { + target.ObjCProtocols = (target.ObjCProtocols && target.ObjCProtocols instanceof Array ? ArrayPrototypeConcat(target.ObjCProtocols, protocols) : ArrayPrototypeSlice(protocols)); + } + if (methods) { + if (!target.ObjCExposedMethods) { + target.ObjCExposedMethods = {}; + } + var methodKeys = ObjectKeys(methods); + for (var mi = 0; mi < methodKeys.length; mi++) { + var selector = methodKeys[mi]; + target.ObjCExposedMethods[selector] = methods[selector]; + } + } + if (name) { + target.ObjCClassName = name; + if (typeof target.class === 'function') { + target.class(); + } + } + return target; +} + ObjectAssign(global, { CGPointMake(x, y) { return new CGPoint({ x, y }); @@ -51,14 +79,11 @@ ObjectAssign(global, { }, NativeClass(arg) { if (typeof arg === 'function') { - return arg; + return applyNativeClassOptions(arg, {}); } var options = arg || {}; return function (target) { - if (options.protocols && options.protocols.length > 0) { - target.ObjCProtocols = (target.ObjCProtocols && target.ObjCProtocols instanceof Array ? ArrayPrototypeConcat(target.ObjCProtocols, options.protocols) : ArrayPrototypeSlice(options.protocols)); - } - return target; + return applyNativeClassOptions(target, options); } }, ObjCMethod() { diff --git a/types/index.d.ts b/types/index.d.ts index dec044f1..4eaa2f4c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,8 +1,8 @@ -// Type declarations for the runtime's public `ns:` builtin modules — one -// .d.ts per module, mirroring @types/node's layout and the "one module, one -// source file" rule in docs/ns-builtin-modules.md. The declared surfaces must -// stay in sync with that document (NsRuntimeTests.js / NsUtilTests.js assert -// the export sets at runtime). +// Type declarations for the runtime's public surfaces — `ns:` builtin modules +// (one .d.ts per module, mirroring @types/node) and globals such as +// NativeClass. The `ns:` surfaces must stay in sync with +// docs/ns-builtin-modules.md (NsRuntimeTests.js / NsUtilTests.js assert the +// export sets at runtime). // // `ns:` is not a resolvable package specifier, so these are ambient // declarations: they apply program-wide once this file is in the TypeScript @@ -24,3 +24,4 @@ /// /// +/// diff --git a/types/ns-nativeclass.d.ts b/types/ns-nativeclass.d.ts new file mode 100644 index 00000000..e27a694b --- /dev/null +++ b/types/ns-nativeclass.d.ts @@ -0,0 +1,41 @@ +/** + * Decorates a class that extends a native type. All properties are optional. + * This runtime implements the `ios` side; `android` is accepted and ignored. + * + * `@NativeClass` and `@NativeClass({ ios: { ... } })` are both valid. + * Passing the class directly (`NativeClass(MyClass)`) applies empty options. + */ +interface NativeClassIOSMethodSignature { + returns?: any; + params?: any[]; +} + +interface NativeClassIOSOptions { + /** + * Objective-C class name to register eagerly. When omitted, the ES class + * name is used and registration stays lazy until first native use. + */ + name?: string; + protocols?: any[]; + /** + * Maps to `static ObjCExposedMethods`. Keys are Objective-C selectors. + */ + methods?: { [selector: string]: NativeClassIOSMethodSignature }; +} + +interface NativeClassAndroidOptions { + name?: string; + interfaces?: any[]; +} + +interface NativeClassOptions { + ios?: NativeClassIOSOptions; + android?: NativeClassAndroidOptions; +} + +declare function NativeClass( + constructor: T +): T; +declare function NativeClass( + options?: NativeClassOptions +): (constructor: T) => T; From 28ee92c037898ff596414962408e3adb903a9b80 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 14 Aug 2026 13:42:02 -0700 Subject: [PATCH 4/6] feat: adopt native-born ES class instances into a real construct --- NativeScript/runtime/ArgConverter.mm | 74 ++++++++- NativeScript/runtime/Caches.h | 6 + .../app/tests/Inheritance/ESClassTests.js | 140 ++++++++++++++++-- 3 files changed, 201 insertions(+), 19 deletions(-) diff --git a/NativeScript/runtime/ArgConverter.mm b/NativeScript/runtime/ArgConverter.mm index 56f1ab2a..7e9b90f5 100644 --- a/NativeScript/runtime/ArgConverter.mm +++ b/NativeScript/runtime/ArgConverter.mm @@ -15,6 +15,8 @@ namespace tns { +static bool TryConstructESDerivedInstance(Local context, id target, Local& out); + void ArgConverter::Init(Local context, NamedPropertyGetterCallback structPropertyGetter, NamedPropertySetterCallbackV2 structPropertySetter) { Isolate* isolate = v8::Isolate::GetCurrent(); @@ -568,7 +570,16 @@ // be claimed with takeRetainedValue — so that path takes its own reference. bool resultIsOwned = false; - if (info.Length() == 1) { + auto cache = Caches::Get(isolate); + if (cache->PendingESAdopt != nullptr) { + // Adopt path: native already created this object. Bind it to the ES + // construct and do not alloc/init again (that would be N2, or recurse). + result = (__bridge id)cache->PendingESAdopt; + cache->PendingESAdopt = nullptr; + resultIsOwned = false; + } + + if (result == nil && info.Length() == 1) { BaseDataWrapper* wrapper = tns::GetValue(isolate, info[0]); if (wrapper != nullptr && wrapper->Type() == WrapperType::Pointer) { PointerWrapper* pointerWrapper = static_cast(wrapper); @@ -599,7 +610,6 @@ resultIsOwned = true; } - auto cache = Caches::Get(isolate); auto poInstance = ArgConverter::FindCachedInstance(isolate, cache, result); if (poInstance != nullptr) { // An initializer that answered with an already wrapped object (a singleton, @@ -816,6 +826,53 @@ return args; } +static bool TryConstructESDerivedInstance(Local context, id target, Local& out) { + Isolate* isolate = context->GetIsolate(); + auto cache = Caches::Get(isolate); + if (cache->PendingESAdopt != nullptr) { + return false; + } + + const char* className = object_getClassName(target); + if (className == nullptr) { + return false; + } + + auto it = cache->CtorFuncs.find(std::string_view(className)); + if (it == cache->CtorFuncs.end()) { + return false; + } + + Local ctor = it->second->Get(isolate); + BaseDataWrapper* ctorWrapper = tns::GetValue(isolate, ctor); + if (ctorWrapper == nullptr || ctorWrapper->Type() != WrapperType::ObjCClass) { + return false; + } + if (!static_cast(ctorWrapper)->ESDerivedClass()) { + return false; + } + + cache->PendingESAdopt = (__bridge void*)target; + TryCatch tc(isolate); + Local constructed; + bool ok = ctor->CallAsConstructor(context, 0, nullptr).ToLocal(&constructed); + cache->PendingESAdopt = nullptr; + if (!ok) { + throw NativeScriptException(isolate, tc, "Failed to construct ES class for native instance"); + } + + auto cached = ArgConverter::FindCachedInstance(isolate, cache, target); + if (cached != nullptr) { + out = cached->Get(isolate); + return true; + } + if (!constructed.IsEmpty() && constructed->IsObject()) { + out = constructed; + return true; + } + return false; +} + Local ArgConverter::CreateJsWrapper(Local context, BaseDataWrapper* wrapper, Local receiver, bool skipGCRegistration, const std::vector& additionalProtocols) { @@ -892,10 +949,17 @@ auto cache = Caches::Get(isolate); if (receiver.IsEmpty()) { - auto it = cache->Instances.find(target); - if (it != cache->Instances.end()) { - receiver = it->second->Get(isolate).As(); + auto cached = ArgConverter::FindCachedInstance(isolate, cache, target); + if (cached != nullptr) { + receiver = cached->Get(isolate).As(); } else { + tns::Assert(cache->PendingESAdopt != (__bridge void*)target, isolate); + + Local constructed; + if (TryConstructESDerivedInstance(context, target, constructed)) { + return constructed; + } + std::shared_ptr> poValue = CreateEmptyObject(context, skipGCRegistration); receiver = poValue->Get(isolate).As(); tns::SetValue(isolate, receiver, wrapper); diff --git a/NativeScript/runtime/Caches.h b/NativeScript/runtime/Caches.h index 6fbc0b10..7c60dd80 100644 --- a/NativeScript/runtime/Caches.h +++ b/NativeScript/runtime/Caches.h @@ -144,6 +144,12 @@ class Caches { robin_hood::unordered_map>> Instances; + + // Native object being adopted by an in-flight ES construct (CreateJsWrapper + // → CallAsConstructor → super()). void* so this header stays includable + // from C++ TUs; .mm files cast to/from id. ConstructObject consumes it + // so super() binds that id and does not alloc/init again. + void* PendingESAdopt = nullptr; robin_hood::unordered_map, std::shared_ptr>, pair_hash> diff --git a/TestRunner/app/tests/Inheritance/ESClassTests.js b/TestRunner/app/tests/Inheritance/ESClassTests.js index f0ced74c..5a4dc592 100644 --- a/TestRunner/app/tests/Inheritance/ESClassTests.js +++ b/TestRunner/app/tests/Inheritance/ESClassTests.js @@ -151,10 +151,10 @@ describe(module.id, function () { expect(NSStringFromClass(object.class())).toBe('ESAllocObject'); }); - it('ESClassAllocInitDoesNotRunJsConstructor', function () { + it('ESClassAllocInitRunsJsConstructor', function () { var constructorRuns = 0; - class ESAllocNoCtorObject extends NSObject { + class ESAllocCtorObject extends NSObject { field = 42; constructor() { @@ -164,21 +164,96 @@ describe(module.id, function () { } } - // alloc().init() is purely native initialization: the JS constructor body and - // class field initializers only run through `new`, never through alloc/init. - var allocated = ESAllocNoCtorObject.alloc().init(); - expect(constructorRuns).toBe(0); - expect(allocated.field).toBe(undefined); - expect(allocated.initializedFromJs).toBe(undefined); - expect(allocated instanceof ESAllocNoCtorObject).toBe(true); - expect(NSStringFromClass(allocated.class())).toBe('ESAllocNoCtorObject'); - - var constructed = new ESAllocNoCtorObject(); + // Objects Objective-C allocates — alloc/init here, but equally cell reuse, + // storyboards or NSCoding — are adopted into a real ES construct so class + // fields and the constructor body run on both paths. + var allocated = ESAllocCtorObject.alloc().init(); expect(constructorRuns).toBe(1); + expect(allocated.field).toBe(42); + expect(allocated.initializedFromJs).toBe(true); + expect(allocated instanceof ESAllocCtorObject).toBe(true); + expect(NSStringFromClass(allocated.class())).toBe('ESAllocCtorObject'); + + var constructed = new ESAllocCtorObject(); + expect(constructorRuns).toBe(2); expect(constructed.field).toBe(42); expect(constructed.initializedFromJs).toBe(true); }); + it('ESClassAllocInitPrivateFields', function () { + class ESPrivateAllocObject extends NSObject { + #a = 1; + + constructor() { + super(); + } + + someMethod() { + return this.#a; + } + } + + expect(new ESPrivateAllocObject().someMethod()).toBe(1); + expect(ESPrivateAllocObject.alloc().init().someMethod()).toBe(1); + }); + + it('ESClassAllocInitDoesNotDoubleAlloc', function () { + class ESAdoptOnceObject extends TNSCInterface { + constructor() { + super({ primitive: 7 }); + } + } + + TNSClearOutput(); + var allocated = ESAdoptOnceObject.alloc().init(); + // Native already called init; adopt must not run initWithPrimitive. + expect(TNSGetOutput()).toBe('init called'); + expect(allocated instanceof ESAdoptOnceObject).toBe(true); + + TNSClearOutput(); + var constructed = new ESAdoptOnceObject(); + expect(TNSGetOutput()).toBe('initWithPrimitive:7 called'); + expect(constructed instanceof ESAdoptOnceObject).toBe(true); + }); + + it('ESClassSuperObjectTokensSelectInitializer', function () { + class ESTokenCtorObject extends TNSCInterface { + constructor() { + super({ primitive: 7 }); + } + } + + TNSClearOutput(); + var object = new ESTokenCtorObject(); + expect(object instanceof ESTokenCtorObject).toBe(true); + expect(TNSGetOutput()).toBe('initWithPrimitive:7 called'); + }); + + it('ESClassAllocInitThrowingConstructor', function () { + class ESThrowingCtorObject extends NSObject { + constructor() { + super(); + throw new Error('adopt construct failed'); + } + } + + var threw = false; + try { + ESThrowingCtorObject.alloc().init(); + } catch (e) { + threw = true; + } + expect(threw).toBe(true); + + threw = false; + try { + new ESThrowingCtorObject(); + } catch (e) { + threw = true; + } + expect(threw).toBe(true); + }); + it('ESClassNewBeforeConstruction', function () { class ESNewObject extends NSObject { } @@ -327,7 +402,7 @@ describe(module.id, function () { expect(object instanceof PlainBase).toBe(true); }); - it('NativeClassGlobalDecoratorNoop', function () { + it('NativeClassDecoratorAppliesIOSOptions', function () { expect(typeof global.NativeClass).toBe('function'); const ESDecoratedPlain = NativeClass(class ESDecoratedPlainObject extends NSObject { @@ -335,7 +410,11 @@ describe(module.id, function () { var instance = new ESDecoratedPlain(); expect(instance instanceof ESDecoratedPlain).toBe(true); - const ESDecoratedProtocols = NativeClass({ protocols: [TNSBaseProtocol2] })( + const ESDecoratedProtocols = NativeClass({ + ios: { + protocols: [TNSBaseProtocol2] + } + })( class ESDecoratedProtocolsObject extends NSObject { baseProtocolMethod1() { TNSLog('baseProtocolMethod1 called'); @@ -351,4 +430,37 @@ describe(module.id, function () { expect(TNSGetOutput()).toBe('baseProtocolMethod1 called' + 'baseProtocolMethod2 called'); }); + + it('NativeClassEagerNameRegistersImmediately', function () { + const ESEagerNamed = NativeClass({ + ios: { + name: 'ESEagerNamedObject' + } + })(class UnusedJsNameForEager extends NSObject { + }); + + expect(NSClassFromString('ESEagerNamedObject')).toBe(ESEagerNamed); + expect(NSStringFromClass(ESEagerNamed)).toBe('ESEagerNamedObject'); + expect(new ESEagerNamed() instanceof ESEagerNamed).toBe(true); + }); + + it('NativeClassExposedMethodsFromIOSOptions', function () { + const ESDecoratedExposed = NativeClass({ + ios: { + methods: { + 'voidSelector': { returns: interop.types.void } + } + } + })( + class ESDecoratedExposedObject extends NSObject { + voidSelector() { + TNSLog('voidSelector called'); + } + } + ); + + var object = new ESDecoratedExposed(); + TNSTestNativeCallbacks.inheritanceVoidSelector(object); + expect(TNSGetOutput()).toBe('voidSelector called'); + }); }); From ca9af711baad50dcd71fde29b400ae98bc2d76f4 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 14 Aug 2026 14:08:44 -0700 Subject: [PATCH 5/6] ci: build --- NativeScript/runtime/ArgConverter.mm | 4 ++-- NativeScript/runtime/ClassBuilder.mm | 6 +++--- NativeScript/runtime/MetadataBuilder.mm | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/NativeScript/runtime/ArgConverter.mm b/NativeScript/runtime/ArgConverter.mm index 7e9b90f5..fdaa8e97 100644 --- a/NativeScript/runtime/ArgConverter.mm +++ b/NativeScript/runtime/ArgConverter.mm @@ -827,7 +827,7 @@ } static bool TryConstructESDerivedInstance(Local context, id target, Local& out) { - Isolate* isolate = context->GetIsolate(); + Isolate* isolate = v8::Isolate::GetCurrent(); auto cache = Caches::Get(isolate); if (cache->PendingESAdopt != nullptr) { return false; @@ -838,7 +838,7 @@ static bool TryConstructESDerivedInstance(Local context, id target, Loc return false; } - auto it = cache->CtorFuncs.find(std::string_view(className)); + auto it = cache->CtorFuncs.find(className); if (it == cache->CtorFuncs.end()) { return false; } diff --git a/NativeScript/runtime/ClassBuilder.mm b/NativeScript/runtime/ClassBuilder.mm index 3c87e1a0..9f5e75dc 100644 --- a/NativeScript/runtime/ClassBuilder.mm +++ b/NativeScript/runtime/ClassBuilder.mm @@ -422,7 +422,7 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { } Class ClassBuilder::EnsureExtendedClass(Local context, Local ctorFunc) { - Isolate* isolate = context->GetIsolate(); + Isolate* isolate = v8::Isolate::GetCurrent(); // Already registered (or a native/extended constructor that carries a class wrapper) BaseDataWrapper* existingWrapper = tns::GetValue(isolate, ctorFunc); @@ -552,7 +552,7 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { return fallback; } - Isolate* isolate = context->GetIsolate(); + Isolate* isolate = v8::Isolate::GetCurrent(); Local newTargetFunc = newTarget.As(); BaseDataWrapper* wrapper = tns::GetValue(isolate, newTargetFunc); @@ -743,7 +743,7 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { Local exposedMethods, Local exposedProtocols, Local implementationObject, std::unordered_set* visitedNames) { - Isolate* isolate = context->GetIsolate(); + Isolate* isolate = v8::Isolate::GetCurrent(); std::vector protocols; if (!exposedProtocols.IsEmpty() && exposedProtocols->IsArray()) { Local protocolsArray = exposedProtocols.As(); diff --git a/NativeScript/runtime/MetadataBuilder.mm b/NativeScript/runtime/MetadataBuilder.mm index b8dfb444..9d2e21ad 100644 --- a/NativeScript/runtime/MetadataBuilder.mm +++ b/NativeScript/runtime/MetadataBuilder.mm @@ -861,7 +861,7 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta return fallback; } - Isolate* isolate = context->GetIsolate(); + Isolate* isolate = v8::Isolate::GetCurrent(); BaseDataWrapper* wrapper = tns::GetValue(isolate, receiver); if (wrapper != nullptr) { if (wrapper->Type() == WrapperType::ObjCClass) { From f61c29c1dbc08c49072703913e16cb16bda4f320 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 14 Aug 2026 15:06:28 -0700 Subject: [PATCH 6/6] fix: keep NativeClass and ES class registration as a no-op on workers --- NativeScript/runtime/ClassBuilder.mm | 7 +++++++ NativeScript/runtime/Runtime.mm | 7 +++++++ NativeScript/runtime/js/inline-functions.js | 4 ++++ .../app/tests/Inheritance/ESClassTests.js | 17 +++++++++++++++++ types/ns-nativeclass.d.ts | 2 ++ 5 files changed, 37 insertions(+) diff --git a/NativeScript/runtime/ClassBuilder.mm b/NativeScript/runtime/ClassBuilder.mm index 9f5e75dc..e3cef17d 100644 --- a/NativeScript/runtime/ClassBuilder.mm +++ b/NativeScript/runtime/ClassBuilder.mm @@ -433,6 +433,13 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { return nil; } + // Only the main isolate mints ES-derived ObjC classes. Workers keep + // NativeClass / lazy registration as a no-op so they cannot claim + // process-global names. Legacy `.extend()` still scopes names per isolate. + if (Runtime::IsWorker()) { + return nil; + } + // 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 an // ObjCClassWrapper. ES-registered ancestors are flattened into this registration; legacy diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index d16ae32d..2547cd4b 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -523,6 +523,13 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { tns::Assert(false, isolate); } + if (isWorker && !global + ->DefineOwnProperty(context, ToV8String(isolate, "__ns__worker"), + v8::True(isolate), readOnlyFlags) + .FromMaybe(false)) { + tns::Assert(false, isolate); + } + if (isWorker) { // Register proper interop types for worker context // Worker bundles need full interop functionality, not just simple stubs diff --git a/NativeScript/runtime/js/inline-functions.js b/NativeScript/runtime/js/inline-functions.js index cdd4fe3b..50c117c1 100644 --- a/NativeScript/runtime/js/inline-functions.js +++ b/NativeScript/runtime/js/inline-functions.js @@ -9,6 +9,10 @@ const { } = primordials; function applyNativeClassOptions(target, options) { + // Workers must not mint or rename process-global native classes. + if (global.__ns__worker) { + return target; + } var ios = options && options.ios; var protocols = (ios && ios.protocols) || (options && options.protocols); var methods = (ios && ios.methods) || (options && options.methods); diff --git a/TestRunner/app/tests/Inheritance/ESClassTests.js b/TestRunner/app/tests/Inheritance/ESClassTests.js index 5a4dc592..4e64cecc 100644 --- a/TestRunner/app/tests/Inheritance/ESClassTests.js +++ b/TestRunner/app/tests/Inheritance/ESClassTests.js @@ -444,6 +444,23 @@ describe(module.id, function () { expect(new ESEagerNamed() instanceof ESEagerNamed).toBe(true); }); + it('NativeClassIsNoOpOnWorkers', function (done) { + var worker = new Worker('~/shared/Workers/EvalWorker.js'); + worker.onmessage = function (msg) { + worker.terminate(); + expect(msg.data.isFunction).toBe(true); + expect(msg.data.isWorker).toBe(true); + expect(msg.data.hasName).toBe(false); + expect(msg.data.registered).toBe(false); + expect(NSClassFromString('TNSWorkerNativeClassName')).toBeNull(); + done(); + }; + worker.postMessage({ + eval: "var C = NativeClass({ ios: { name: 'TNSWorkerNativeClassName' } })(class TNSWorkerNativeClass extends NSObject {}); " + + "postMessage({ isFunction: typeof NativeClass === 'function', isWorker: !!__ns__worker, hasName: C.ObjCClassName === 'TNSWorkerNativeClassName', registered: NSClassFromString('TNSWorkerNativeClassName') !== null });" + }); + }); + it('NativeClassExposedMethodsFromIOSOptions', function () { const ESDecoratedExposed = NativeClass({ ios: { diff --git a/types/ns-nativeclass.d.ts b/types/ns-nativeclass.d.ts index e27a694b..1fca3460 100644 --- a/types/ns-nativeclass.d.ts +++ b/types/ns-nativeclass.d.ts @@ -4,6 +4,8 @@ * * `@NativeClass` and `@NativeClass({ ios: { ... } })` are both valid. * Passing the class directly (`NativeClass(MyClass)`) applies empty options. + * On worker isolates this is a no-op; only the main isolate registers + * native ES classes. */ interface NativeClassIOSMethodSignature { returns?: any;