From caa95ebfa4de074be5bb4aeaf81d932dbfbb1723 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 9 Jul 2026 13:39:15 -0700 Subject: [PATCH 1/3] 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 | 380 +++++++++++++----- NativeScript/runtime/DataWrapper.h | 12 +- NativeScript/runtime/InlineFunctions.cpp | 4 +- NativeScript/runtime/Interop.mm | 18 + NativeScript/runtime/MetadataBuilder.mm | 67 ++- NativeScript/runtime/js/inline-functions.js | 12 + .../app/tests/Inheritance/ESClassTests.js | 287 +++++++++++++ TestRunner/app/tests/index.js | 1 + 9 files changed, 689 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..1be3cf21 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,117 +302,261 @@ 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(); - } - } + ClassBuilder::SwizzleRetainRelease(isolate, extendedClass); - return retain(self, @selector(retain)); - }); - class_addMethod(extendedClass, @selector(retain), newRetain, "@@:"); + info.GetReturnValue().SetUndefined(); + }).ToLocalChecked(); - 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; + PropertyAttribute flags = static_cast(PropertyAttribute::DontDelete); + bool success = + global->DefineOwnProperty(context, tns::ToV8String(isolate, "__extends"), extendsFunc, flags) + .FromMaybe(false); + 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(); - } - } - } + 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@:"); + release(self, @selector(release)); + }); + class_addMethod(extendedClass, @selector(release), newRelease, "v@:"); +} - info.GetReturnValue().SetUndefined(); - }).ToLocalChecked(); +Class ClassBuilder::EnsureExtendedClass(Local context, Local ctorFunc) { + Isolate* isolate = v8::Isolate::GetCurrent(); - PropertyAttribute flags = static_cast(PropertyAttribute::DontDelete); + // 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 = - global->DefineOwnProperty(context, tns::ToV8String(isolate, "__extends"), extendsFunc, flags) - .FromMaybe(false); - tns::Assert(success, isolate); + 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 = v8::Isolate::GetCurrent(); + 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, @@ -589,7 +733,8 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { void ClassBuilder::ExposeDynamicMethods(Local context, Class extendedClass, Local exposedMethods, Local exposedProtocols, - Local implementationObject) { + Local implementationObject, + std::unordered_set* visitedNames) { Isolate* isolate = v8::Isolate::GetCurrent(); std::vector protocols; if (!exposedProtocols.IsEmpty() && exposedProtocols->IsArray()) { @@ -624,6 +769,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 +863,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 +887,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 +905,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..74ed1014 100644 --- a/NativeScript/runtime/DataWrapper.h +++ b/NativeScript/runtime/DataWrapper.h @@ -390,8 +390,11 @@ 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 +402,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 abce884f..e7ce315e 100644 --- a/NativeScript/runtime/InlineFunctions.cpp +++ b/NativeScript/runtime/InlineFunctions.cpp @@ -21,8 +21,8 @@ bool InlineFunctions::IsGlobalFunction(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 fb29bbf2..e2a5e22e 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" @@ -576,6 +577,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(); @@ -730,6 +740,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 43d2a3eb..b2f208f1 100644 --- a/NativeScript/runtime/MetadataBuilder.mm +++ b/NativeScript/runtime/MetadataBuilder.mm @@ -721,6 +721,11 @@ 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) { @@ -734,20 +739,27 @@ 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); @@ -766,15 +778,24 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta std::string className = item->className_; + Local context = isolate->GetCurrentContext(); Local thiz = info.This(); if (thiz->IsFunction()) { - if (BaseDataWrapper* wrapper = tns::GetValue(isolate, thiz)) { + BaseDataWrapper* wrapper = tns::GetValue(isolate, thiz); + if (wrapper != nullptr) { ObjCClassWrapper* classWrapper = static_cast(wrapper); className = class_getName(classWrapper->Klass()); + } 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) { + className = class_getName(ensured); + } } } - Local context = isolate->GetCurrentContext(); Local result = instanceMethod ? MetadataBuilder::InvokeMethod(context, item->meta_, info.This(), args, className, true) @@ -830,6 +851,32 @@ 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 = v8::Isolate::GetCurrent(); + 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*>( @@ -842,8 +889,9 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta V8EmptyValueArgs args; Local context = isolate->GetCurrentContext(); - Local result = MetadataBuilder::InvokeMethod( - context, item->meta_->getter(), Local(), args, item->className_, false); + std::string className = ResolveStaticReceiverClassName(context, info.This(), item->className_); + Local result = MetadataBuilder::InvokeMethod(context, item->meta_->getter(), + Local(), args, className, false); if (!result.IsEmpty()) { info.GetReturnValue().Set(result); } @@ -863,8 +911,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..5abffaeb 100644 --- a/NativeScript/runtime/js/inline-functions.js +++ b/NativeScript/runtime/js/inline-functions.js @@ -34,6 +34,18 @@ ObjectAssign(global, { return function (target, key) { decorator(target, key, paramIndex); } }, + 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) : ArrayPrototypeConcat([], options.protocols)); + } + return target; + } + }, ObjCClass() { // Index loop, not ArrayFrom/spread: this runs at class-definition // time, when user code could already have tampered the array 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 d07ccff812ffad6903b5c6ed5aee693b0b8d6ca8 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 9 Jul 2026 14:31:08 -0700 Subject: [PATCH 2/3] 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 07b124a42e2a815dfae066c2eac9540776da0b80 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 13 Aug 2026 16:46:43 -0300 Subject: [PATCH 3/3] feat(es-classes): make NativeClass load-bearing and refuse the ambiguous forms Registration stays lazy for a plain `class X extends NSObject {}`, but the NativeClass decorator now carries the declarations that need a deterministic home, and the two construction paths that silently disagreed now either work or fail loudly. NativeClass accepts `{ protocols, exposedMethods, name, eager }` and works in both decorator conventions plus the plain call form. A standard (TC39) class decorator runs before static field initializers, so eager registration defers through context.addInitializer when a decorator context is present. - `name` sets ObjCClassName, read as an own property so a subclass cannot inherit its base's name. Minifiers mangle the inferred constructor name and every name-based native lookup with it. - `eager` registers at definition time through the new __registerNativeClass global, so storyboards and NSClassFromString resolve the class before first use. No-op on workers, whose names are isolate-suffixed. - super(...) from an ES subclass now requires no arguments or a single initializer-token object. Positional arguments matched on JS value shape, so initWithInt: and initWithDouble: were indistinguishable and the winner depended on the call site; the token form is also the only form the generated .d.ts can express. Direct `new NSString('x')` is unaffected. - An ES class extending a legacy .extend() class now throws instead of falling back to the parent's registered class, which registered nothing and dispatched the subclass's overrides to the parent's implementations. - Registering a class on a path where Objective-C allocates it warns, in debug builds, when the class declares an explicit constructor or instance fields and does not override init. Those objects reach JS as a bare object with the prototype grafted on, so fields are absent and #private access throws. --- NativeScript/runtime/ClassBuilder.h | 27 +- NativeScript/runtime/ClassBuilder.mm | 238 +++++++++++++++++- NativeScript/runtime/Interop.mm | 4 +- NativeScript/runtime/MetadataBuilder.mm | 5 +- NativeScript/runtime/Runtime.mm | 1 + NativeScript/runtime/js/inline-functions.js | 49 +++- .../app/tests/Inheritance/ESClassTests.js | 173 ++++++++++++- 7 files changed, 467 insertions(+), 30 deletions(-) diff --git a/NativeScript/runtime/ClassBuilder.h b/NativeScript/runtime/ClassBuilder.h index fa56764d..321b2878 100644 --- a/NativeScript/runtime/ClassBuilder.h +++ b/NativeScript/runtime/ClassBuilder.h @@ -3,6 +3,7 @@ #include #include +#include #include "Common.h" #include "Metadata.h" @@ -41,14 +42,22 @@ class ClassBuilder { static std::string GetTypeEncoding(const TypeEncoding* typeEncoding, int argsCount); + // Defines the global `__registerNativeClass(ctor)` used by NativeClass to + // register eagerly. No-ops on worker isolates, whose class names are + // isolate-suffixed and so cannot answer name-based native lookups. + static void RegisterNativeClassFunction(v8::Local context); + // 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. + // chain; throws when the chain goes through a legacy `.extend()`-created + // class. Idempotent: subsequent calls return the cached class from the ctor's + // ObjCClassWrapper. Pass nativeAllocates when the caller is handing the class + // to Objective-C (or allocating through it), which is what makes the JS + // constructor unreachable and gates the debug diagnostic. static Class EnsureExtendedClass(v8::Local context, - v8::Local ctorFunc); + v8::Local ctorFunc, + bool nativeAllocates = false); // Resolves the Objective-C class that should be instantiated for a construct // call, honoring `new.target` so that plain ES subclasses of native classes @@ -57,6 +66,13 @@ class ClassBuilder { v8::Local newTarget, Class fallback); + // Throws unless a super(...) call from a plain ES subclass uses a form that + // names exactly one initializer. Call after ResolveConstructedClass, which + // is what marks new.target as ES-derived. + static void ValidateEsClassSuperArgs( + v8::Local context, + const v8::FunctionCallbackInfo& info); + private: static std::atomic classNameCounter_; @@ -67,6 +83,9 @@ class ClassBuilder { const v8::FunctionCallbackInfo& info); static void SwizzleRetainRelease(v8::Isolate* isolate, Class extendedClass); + static void WarnIfConstructorIsUnreachable( + v8::Local context, + const std::vector>& chainCtors, Class extendedClass); static void ExposeDynamicMethods( v8::Local context, Class extendedClass, v8::Local exposedMethods, diff --git a/NativeScript/runtime/ClassBuilder.mm b/NativeScript/runtime/ClassBuilder.mm index 1be3cf21..ac734e92 100644 --- a/NativeScript/runtime/ClassBuilder.mm +++ b/NativeScript/runtime/ClassBuilder.mm @@ -11,6 +11,7 @@ #include "NativeScriptException.h" #include "ObjectManager.h" #include "Runtime.h" +#include "RuntimeConfig.h" #include "TNSDerivedClass.h" using namespace v8; @@ -29,6 +30,84 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { name += std::to_string(isolateId); } } + +// Both an explicit constructor and class fields are erased from the prototype, +// and V8 exposes no API for either, so source text is the only signal. Only +// ever gates a debug warning - a miss costs a log line, never behavior. +bool DeclaresConstructorOrFields(const std::string& src) { + size_t i = src.find('{'); + if (i == std::string::npos) { + return false; + } + + int depth = 0; + bool pendingStatic = false; + for (++i; i < src.size(); i++) { + char c = src[i]; + if (c == '/' && i + 1 < src.size()) { + if (src[i + 1] == '/') { + i = src.find('\n', i); + if (i == std::string::npos) break; + continue; + } + if (src[i + 1] == '*') { + i = src.find("*/", i); + if (i == std::string::npos) break; + i++; + continue; + } + } + if (c == '"' || c == '\'' || c == '`') { + for (++i; i < src.size() && src[i] != c; i++) { + if (src[i] == '\\') i++; + } + continue; + } + if (c == '{' || c == '(' || c == '[') { + depth++; + continue; + } + if (c == '}' || c == ')' || c == ']') { + if (c == '}' && depth == 0) break; + depth--; + continue; + } + if (depth != 0) { + continue; + } + + if (isalpha(c) || c == '_' || c == '$' || c == '#') { + size_t start = i; + while (i < src.size() && (isalnum(src[i]) || src[i] == '_' || src[i] == '$' || src[i] == '#')) + i++; + std::string word = src.substr(start, i - start); + size_t j = i; + while (j < src.size() && isspace(src[j])) j++; + if (j >= src.size()) break; + + // Static fields initialize with the class itself, so they survive native + // allocation - only the instance members are at risk. + if (word == "static" && src[j] != '=' && src[j] != '(') { + pendingStatic = true; + i--; + continue; + } + + if (word == "constructor" && src[j] == '(') { + return true; + } + // `name = value` at class-body top level is a field initializer; + // `==`/`=>` are not. + if (!pendingStatic && src[j] == '=' && j + 1 < src.size() && src[j + 1] != '=' && + src[j + 1] != '>') { + return true; + } + pendingStatic = false; + i--; + } + } + return false; +} } // namespace Local ClassBuilder::GetExtendFunction(Isolate* isolate, @@ -421,7 +500,49 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { class_addMethod(extendedClass, @selector(release), newRelease, "v@:"); } -Class ClassBuilder::EnsureExtendedClass(Local context, Local ctorFunc) { +void ClassBuilder::RegisterNativeClassFunction(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + Local global = context->Global(); + + Local registerFunc = + v8::Function::New(context, [](const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local context = isolate->GetCurrentContext(); + + if (info.Length() < 1 || !info[0]->IsFunction()) { + info.GetReturnValue().Set(false); + return; + } + + // Eager registration exists so the class answers to its source name + // (storyboards, NSClassFromString, UIApplicationMain). A worker's + // classes are isolate-suffixed, so there is no name to claim and the + // request is dropped; the class still registers lazily on first use. + if (Runtime::IsWorker()) { + info.GetReturnValue().Set(false); + return; + } + + try { + Class klass = + ClassBuilder::EnsureExtendedClass(context, info[0].As(), true); + info.GetReturnValue().Set(klass != nil); + } catch (NativeScriptException& ex) { + ex.ReThrowToV8(isolate); + } + }).ToLocalChecked(); + + PropertyAttribute flags = + static_cast(PropertyAttribute::DontDelete | PropertyAttribute::DontEnum); + bool success = global + ->DefineOwnProperty(context, tns::ToV8String(isolate, "__registerNativeClass"), + registerFunc, flags) + .FromMaybe(false); + tns::Assert(success, isolate); +} + +Class ClassBuilder::EnsureExtendedClass(Local context, + Local ctorFunc, bool nativeAllocates) { Isolate* isolate = v8::Isolate::GetCurrent(); // Already registered (or a native/extended constructor that carries a class wrapper) @@ -473,8 +594,13 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { continue; } - // Legacy `.extend()`-created base - not supported for ES class chaining. - return nil; + // Legacy `.extend()`-created base. Falling back to the parent's class would + // register nothing and silently dispatch this class's overrides to the + // parent's implementations, so refuse instead. + throw NativeScriptException( + std::string("Cannot extend \"") + class_getName(parentClassWrapper->Klass()) + + "\" with an ES class: it was created by the legacy .extend() API. Convert the base class " + "to an ES class, or declare this one with .extend() as well."); } if (baseClass == nil) { @@ -485,7 +611,25 @@ 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()); + + // An explicit name survives minification, which mangles the inferred ctor + // name and with it any name-based native lookup. + // Own property only: an inherited ObjCClassName would make a subclass try to + // register under its base's name. + std::string className; + Local classNameKey = tns::ToV8String(isolate, "ObjCClassName"); + bool hasOwnName = false; + if (ctorFunc->HasOwnProperty(context, classNameKey.As()).To(&hasOwnName) && + hasOwnName) { + Local explicitName; + if (ctorFunc->Get(context, classNameKey).ToLocal(&explicitName) && !explicitName.IsEmpty() && + explicitName->IsString()) { + className = tns::ToString(isolate, explicitName); + } + } + if (className.empty()) { + className = tns::ToString(isolate, ctorFunc->GetName()); + } ScopeClassNameToIsolate(className, isolateId); Class extendedClass = ClassBuilder::GetExtendedClass(baseClassName, className, isolateId); @@ -535,9 +679,56 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { ClassBuilder::SwizzleRetainRelease(isolate, extendedClass); + if (nativeAllocates && RuntimeConfig.IsDebug) { + WarnIfConstructorIsUnreachable(context, chainCtors, extendedClass); + } + return extendedClass; } +// Objects Objective-C allocates itself (cell reuse, storyboards, NSCoding, +// alloc/init) reach JS as a bare object with the class prototype grafted on - +// they never run [[Construct]], so class fields and the constructor body are +// absent and `#private` access throws. Overriding `init` is the only hook both +// paths share. +void ClassBuilder::WarnIfConstructorIsUnreachable(Local context, + const std::vector>& chainCtors, + Class extendedClass) { + Isolate* isolate = v8::Isolate::GetCurrent(); + Local initKey = tns::ToV8String(isolate, "init"); + + for (Local levelCtor : chainCtors) { + Local prototypeValue; + if (!levelCtor->Get(context, tns::ToV8String(isolate, "prototype")).ToLocal(&prototypeValue) || + !prototypeValue->IsObject()) { + continue; + } + bool hasInit = false; + if (prototypeValue.As()->HasOwnProperty(context, initKey).To(&hasInit) && hasInit) { + // The class opts into the construction path Objective-C also takes. + return; + } + } + + for (Local levelCtor : chainCtors) { + Local src; + if (!levelCtor->ToString(context).ToLocal(&src) || src.IsEmpty()) { + continue; + } + if (!DeclaresConstructorOrFields(tns::ToString(isolate, src))) { + continue; + } + + Log(@"NativeScript warning: class %s declares a constructor or class fields, but it is now " + @"registered with the Objective-C runtime, so instances Objective-C creates itself " + @"(alloc/init, cell reuse, storyboards, NSCoding) never run them - those objects will be " + @"missing the fields, and reading a #private field on one throws. Override init() instead: " + @"init() { const self = super.init(); /* setup */ return self; }", + class_getName(extendedClass)); + return; + } +} + Class ClassBuilder::ResolveConstructedClass(Local context, Local newTarget, Class fallback) { if (newTarget.IsEmpty() || !newTarget->IsFunction()) { @@ -559,6 +750,45 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { return ensured != nil ? ensured : fallback; } +void ClassBuilder::ValidateEsClassSuperArgs(Local context, + const FunctionCallbackInfo& info) { + if (info.Length() == 0) { + return; + } + + Isolate* isolate = v8::Isolate::GetCurrent(); + Local newTarget = info.NewTarget(); + if (newTarget.IsEmpty() || !newTarget->IsFunction()) { + return; + } + + BaseDataWrapper* wrapper = tns::GetValue(isolate, newTarget.As()); + if (wrapper == nullptr || wrapper->Type() != WrapperType::ObjCClass || + !static_cast(wrapper)->ESDerivedClass()) { + return; + } + + if (info.Length() == 1 && info[0]->IsObject()) { + BaseDataWrapper* argWrapper = tns::GetValue(isolate, info[0]); + // A token object carries no wrapper; a pointer is the adopt-an-existing- + // instance form. Both name exactly one initializer. + if (argWrapper == nullptr || argWrapper->Type() == WrapperType::Pointer) { + return; + } + } + + // Positional arguments pick an initializer by matching JS value shapes, so + // `initWithInt:` and `initWithDouble:` are indistinguishable and the winner + // depends on the call site. The token form names one selector outright, and + // is the only form the generated .d.ts can type. + throw NativeScriptException( + std::string("super(...) on a class extending \"") + + class_getName(static_cast(wrapper)->Klass()) + + "\" must be called with no arguments or with a single initializer-token object, e.g. " + "super({ frame: rect }) for initWithFrame:. Positional arguments are ambiguous and are not " + "supported."); +} + void ClassBuilder::ExposeDynamicMembers(v8::Local context, Class extendedClass, Local implementationObject, Local nativeSignature) { diff --git a/NativeScript/runtime/Interop.mm b/NativeScript/runtime/Interop.mm index e2a5e22e..dd7f4680 100644 --- a/NativeScript/runtime/Interop.mm +++ b/NativeScript/runtime/Interop.mm @@ -581,7 +581,7 @@ inline bool isBool() { // 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()); + Class ensured = ClassBuilder::EnsureExtendedClass(context, obj.As(), true); if (ensured != nil) { wrapper = tns::GetValue(isolate, obj); } @@ -743,7 +743,7 @@ inline bool isBool() { 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()); + Class ensured = ClassBuilder::EnsureExtendedClass(context, obj.As(), true); if (ensured != nil) { return ensured; } diff --git a/NativeScript/runtime/MetadataBuilder.mm b/NativeScript/runtime/MetadataBuilder.mm index b2f208f1..093216f0 100644 --- a/NativeScript/runtime/MetadataBuilder.mm +++ b/NativeScript/runtime/MetadataBuilder.mm @@ -725,6 +725,7 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta // through super(); use new.target to lazily register (and construct) the // derived class. klass = ClassBuilder::ResolveConstructedClass(context, info.NewTarget(), klass); + ClassBuilder::ValidateEsClassSuperArgs(context, info); const InterfaceMeta* interfaceMeta = static_cast(item->meta_); ArgConverter::ConstructObject(context, info, klass, interfaceMeta); @@ -750,7 +751,7 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta // `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()); + klass = ClassBuilder::EnsureExtendedClass(context, thiz.As(), true); } if (klass == nil) { @@ -789,7 +790,7 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta // 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()); + Class ensured = ClassBuilder::EnsureExtendedClass(context, thiz.As(), true); if (ensured != nil) { className = class_getName(ensured); } diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index d16ae32d..9cfbff52 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -419,6 +419,7 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { context); // Register the __extends function to the global object ClassBuilder::RegisterNativeTypeScriptExtendsFunction( context); // Override the __extends function for native objects + ClassBuilder::RegisterNativeClassFunction(context); // Backs NativeClass({ eager: true }) TSHelpers::Init(context); InlineFunctions::Init(context); diff --git a/NativeScript/runtime/js/inline-functions.js b/NativeScript/runtime/js/inline-functions.js index 5abffaeb..0992b301 100644 --- a/NativeScript/runtime/js/inline-functions.js +++ b/NativeScript/runtime/js/inline-functions.js @@ -7,6 +7,39 @@ const { ObjectKeys, } = primordials; +function applyNativeClass(target, context, options) { + if (options.name) { + // Read back by ClassBuilder::EnsureExtendedClass. Survives minifiers, + // which mangle the inferred constructor name and with it every + // name-based native lookup. + target.ObjCClassName = options.name; + } + + if (options.protocols && options.protocols.length > 0) { + target.ObjCProtocols = (target.ObjCProtocols && target.ObjCProtocols instanceof Array ? ArrayPrototypeConcat(target.ObjCProtocols, options.protocols) : ArrayPrototypeConcat([], options.protocols)); + } + + if (options.exposedMethods) { + // Fresh object: ObjCExposedMethods may be inherited through the static + // chain, and assigning into it would edit the base class's. + target.ObjCExposedMethods = ObjectAssign({}, target.ObjCExposedMethods, options.exposedMethods); + } + + if (options.eager) { + // Standard decorators run before static field initializers, so a class + // whose protocols/exposedMethods come from `static` fields would be + // registered without them. addInitializer defers to after the class is + // fully defined. Legacy decorators and plain calls already run late. + if (context && typeof context.addInitializer === 'function') { + context.addInitializer(function () { __registerNativeClass(this); }); + } else { + __registerNativeClass(target); + } + } + + return target; +} + ObjectAssign(global, { CGPointMake(x, y) { return new CGPoint({ x, y }); @@ -34,16 +67,18 @@ ObjectAssign(global, { return function (target, key) { decorator(target, key, paramIndex); } }, - NativeClass(arg) { + NativeClass(arg, maybeContext) { + // Usable three ways, and the shapes are indistinguishable at the call + // site: `@NativeClass` / `@NativeClass({...})` under either decorator + // proposal, and the plain call `NativeClass(class X {})`. A standard + // (TC39) decorator is handed (value, context); a legacy one and a plain + // call are handed just the class. if (typeof arg === 'function') { - return arg; + return applyNativeClass(arg, maybeContext, {}); } 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) : ArrayPrototypeConcat([], options.protocols)); - } - return target; + return function (target, context) { + return applyNativeClass(target, context, options); } }, ObjCClass() { diff --git a/TestRunner/app/tests/Inheritance/ESClassTests.js b/TestRunner/app/tests/Inheritance/ESClassTests.js index f0ced74c..0e9e20f9 100644 --- a/TestRunner/app/tests/Inheritance/ESClassTests.js +++ b/TestRunner/app/tests/Inheritance/ESClassTests.js @@ -1,6 +1,7 @@ -// 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). +// Tests for native ES class support: plain `class X extends NativeType {}` without ES5 +// downleveling. The Objective-C class is registered lazily by the runtime on first use +// (construction, alloc/new, static dispatch or Class marshalling), or eagerly via +// NativeClass({ eager: true }). describe(module.id, function () { afterEach(function () { TNSClearOutput(); @@ -56,12 +57,12 @@ describe(module.id, function () { expect(NSStringFromClass(object.class())).toBe('ESConstructorObject'); }); - it('ESClassSuperArgsSelectInitializer', function () { + it('ESClassSuperTokenObjectSelectsInitializer', 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); + // The token object names one selector outright. Arguments passed to `new` are + // only seen by the JS constructor. + super({ primitive: x }); this.name = name; } } @@ -74,7 +75,7 @@ describe(module.id, function () { class ESCtorTwoArgsObject extends TNSCInterface { constructor(a, b) { - super(a, b); + super({ int: a, andInt: b }); } } @@ -95,6 +96,30 @@ describe(module.id, function () { expect(TNSGetOutput()).toBe('init called'); }); + it('ESClassPositionalSuperArgsThrow', function () { + class ESPositionalSuperObject extends TNSCInterface { + constructor() { + super(7); + } + } + + // Positional args match on JS value shape, so initWithInt: and initWithPrimitive: + // are indistinguishable. The token form is the only supported spelling. + expect(function () { + new ESPositionalSuperObject(); + }).toThrowError(/initializer-token object/); + + class ESPositionalSuperTwoObject extends TNSCInterface { + constructor() { + super(5, 10); + } + } + + expect(function () { + new ESPositionalSuperTwoObject(); + }).toThrowError(/initializer-token object/); + }); + it('ESClassInstanceMethodsAndSuper', function () { class ESMethodsObject extends TNSDerivedInterface { baseMethod() { @@ -164,8 +189,10 @@ 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. + // Objects Objective-C allocates - alloc/init here, but equally cell reuse, + // storyboards or NSCoding - are handed to JS as a bare object with the class + // prototype grafted on. They never run [[Construct]], so class fields and the + // constructor body are absent. Override init() (below) to run on both paths. var allocated = ESAllocNoCtorObject.alloc().init(); expect(constructorRuns).toBe(0); expect(allocated.field).toBe(undefined); @@ -179,6 +206,25 @@ describe(module.id, function () { expect(constructed.initializedFromJs).toBe(true); }); + it('ESClassInitOverrideRunsOnNativeAllocation', function () { + class ESInitOverrideObject extends NSObject { + init() { + var self = super.init(); + self.readyFromInit = true; + return self; + } + } + + // init() is the one construction hook both paths share + var allocated = ESInitOverrideObject.alloc().init(); + expect(allocated.readyFromInit).toBe(true); + expect(allocated instanceof ESInitOverrideObject).toBe(true); + + var constructed = new ESInitOverrideObject(); + expect(constructed.readyFromInit).toBe(true); + expect(constructed instanceof ESInitOverrideObject).toBe(true); + }); + it('ESClassNewBeforeConstruction', function () { class ESNewObject extends NSObject { } @@ -327,14 +373,37 @@ describe(module.id, function () { expect(object instanceof PlainBase).toBe(true); }); - it('NativeClassGlobalDecoratorNoop', function () { + it('ESClassExtendingLegacyExtendClassThrows', function () { + var LegacyBase = NSObject.extend({ + legacyMethod: function () { + TNSLog('legacyMethod called'); + } + }, { name: 'ESLegacyBaseForChaining' }); + + class ESFromLegacy extends LegacyBase { + legacyMethod() { + TNSLog('overridden'); + } + } + + // Falling back to the parent's class would register nothing and silently dispatch + // this class's overrides to the parent's implementations + expect(function () { + new ESFromLegacy(); + }).toThrowError(/legacy \.extend\(\) API/); + }); + + it('NativeClassPlainCallForm', function () { expect(typeof global.NativeClass).toBe('function'); const ESDecoratedPlain = NativeClass(class ESDecoratedPlainObject extends NSObject { }); var instance = new ESDecoratedPlain(); expect(instance instanceof ESDecoratedPlain).toBe(true); + expect(NSStringFromClass(instance.class())).toBe('ESDecoratedPlainObject'); + }); + it('NativeClassProtocolsOption', function () { const ESDecoratedProtocols = NativeClass({ protocols: [TNSBaseProtocol2] })( class ESDecoratedProtocolsObject extends NSObject { baseProtocolMethod1() { @@ -351,4 +420,86 @@ describe(module.id, function () { expect(TNSGetOutput()).toBe('baseProtocolMethod1 called' + 'baseProtocolMethod2 called'); }); + + it('NativeClassExposedMethodsOption', function () { + const ESDecoratedExposed = NativeClass({ + exposedMethods: { + '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'); + }); + + it('NativeClassNameOption', function () { + // An explicit name survives minification, which mangles the inferred ctor name + const ESRenamed = NativeClass({ name: 'ESExplicitlyNamedObject' })( + class WouldBeMangled extends NSObject { + } + ); + + var object = new ESRenamed(); + expect(NSStringFromClass(object.class())).toBe('ESExplicitlyNamedObject'); + expect(NSClassFromString('ESExplicitlyNamedObject')).toBe(ESRenamed); + }); + + it('NativeClassEagerOption', function () { + const ESEager = NativeClass({ eager: true, name: 'ESEagerlyRegistered' })( + class ESEagerSource extends NSObject { + } + ); + + // Registered at definition time, with no instance and no other native touch, + // so name-based native lookup (storyboards, NSClassFromString) resolves it + expect(NSClassFromString('ESEagerlyRegistered')).toBe(ESEager); + }); + + it('NativeClassLegacyDecoratorEmit', function () { + // What TypeScript emits for `@NativeClass({...})` with experimentalDecorators + class ESLegacyDecorated extends NSObject { + baseProtocolMethod1() { + TNSLog('baseProtocolMethod1 called'); + } + baseProtocolMethod2() { + TNSLog('baseProtocolMethod2 called'); + } + } + var Decorated = __decorate([NativeClass({ protocols: [TNSBaseProtocol2] })], ESLegacyDecorated); + + var object = new Decorated(); + TNSTestNativeCallbacks.protocolImplementationProtocolInheritance(object); + expect(TNSGetOutput()).toBe('baseProtocolMethod1 called' + + 'baseProtocolMethod2 called'); + }); + + it('NativeClassStandardDecoratorEmit', function () { + // What a TC39 class decorator is handed: (value, context). Class decorators run + // before static field initializers, so eager registration defers via addInitializer. + var initializers = []; + var context = { + kind: 'class', + name: 'ESStandardDecorated', + addInitializer: function (fn) { initializers.push(fn); } + }; + + class ESStandardDecorated extends NSObject { + } + + var result = NativeClass({ eager: true, name: 'ESStandardDecoratedNamed' })( + ESStandardDecorated, context); + expect(result).toBe(ESStandardDecorated); + + // Deferred, not run yet + expect(initializers.length).toBe(1); + expect(NSClassFromString('ESStandardDecoratedNamed')).toBeNull(); + + initializers[0].call(ESStandardDecorated); + expect(NSClassFromString('ESStandardDecoratedNamed')).toBe(ESStandardDecorated); + }); });