From 1efc9436e085a5f205cb6a37f14e06cec303b83d Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 13 Aug 2026 15:00:16 -0300 Subject: [PATCH 1/6] perf(strings): convert NSString to V8 without the UTF-8 round trip ToV8String(Isolate*, NSString*) built a UTF-8 buffer that neither side wanted. -UTF8String encodes the whole string and mallocs an autoreleased buffer, -lengthOfBytesUsingEncoding: encodes it a second time just to count the bytes, and V8 then decodes UTF-8 back into its own one-byte or two-byte form. Three passes and an allocation to move between two representations that already match. Hand CFString's existing buffer to V8 at its native width instead: an ASCII interior pointer feeds NewFromOneByte, a UTF-16 interior pointer feeds NewFromTwoByte, and the shapes that expose neither (tagged pointers, some bridged strings) are copied out once into a stack buffer. Foundation-side cost, measured at -O2 on arm64: ASCII, 15 chars 47.8 ns -> 8.2 ns ASCII, 4000 chars 1040 ns -> 8.0 ns non-ASCII, 17 chars 148 ns -> 7.1 ns CJK, 400 chars 1893 ns -> 7.3 ns tagged pointer 54.2 ns -> 6.2 ns The copy V8 performs is unchanged, so end-to-end this is largest for short strings and settles around 3-8x for multi-KB ones. It also drops one malloc per conversion: -UTF8String allocated on every call for everything but constant literals (10061 mallocs per 10k calls, ~880 KB held until the pool drained). This fixes silent data loss as a side effect. -UTF8String returns nil for a string containing a lone surrogate and -lengthOfBytesUsingEncoding: returns 0, which V8 turns into "" via its length == 0 branch, so a lone surrogate did not survive JS -> native -> JS. The reverse direction was already fixed for exactly this; the bridge is now symmetric. --- NativeScript/runtime/Helpers.h | 77 ++++++++++++------- .../app/tests/Marshalling/NSStringTests.js | 40 ++++++++++ 2 files changed, 91 insertions(+), 26 deletions(-) diff --git a/NativeScript/runtime/Helpers.h b/NativeScript/runtime/Helpers.h index 7611e038..30414190 100644 --- a/NativeScript/runtime/Helpers.h +++ b/NativeScript/runtime/Helpers.h @@ -42,34 +42,59 @@ inline v8::Local ToV8String(v8::Isolate* isolate, const char* value, .ToLocalChecked(); } #ifdef __OBJC__ +// Both sides store text as either 8-bit or UTF-16, never UTF-8, so the buffer is +// handed to V8 in whichever width CFString already holds. Going through +// -UTF8String instead would encode the string twice (once for the bytes, once +// for -lengthOfBytesUsingEncoding:), allocate, and return nil for strings +// containing a lone surrogate — silently turning them into "". inline v8::Local ToV8String(v8::Isolate* isolate, const NSString* value) { - /* - // TODO: profile if this is faster - // maybe have multiple conversion - if([value fastestEncoding] == NSUTF16StringEncoding) { - uint16_t static_buffer[256]; - uint16_t* targetBuffer = static_buffer; - bool isDynamic = false; - auto length = [value - maximumLengthOfBytesUsingEncoding:NSUTF16StringEncoding]; auto numberOfBytes = - length * sizeof(uint16_t); if (length > 256) { targetBuffer = - (uint16_t*)malloc(numberOfBytes); isDynamic = true; - } - NSUInteger usedLength = 0; - NSRange range = NSMakeRange(0, [value length]); - [value getBytes:targetBuffer maxLength:numberOfBytes - usedLength:&usedLength encoding:NSUTF16StringEncoding options:0 range:range - remainingRange:NULL]; - - auto result = v8::String::NewFromTwoByte(isolate, targetBuffer, - v8::NewStringType::kNormal, (int)[value length]).ToLocalChecked(); if - (isDynamic) { free(targetBuffer); - } - return result; + if (value == nil) { + return v8::String::Empty(isolate); } - */ - return v8::String::NewFromUtf8(isolate, [value UTF8String], v8::NewStringType::kNormal, - (int)[value lengthOfBytesUsingEncoding:NSUTF8StringEncoding]) + + CFStringRef str = (__bridge CFStringRef)value; + CFIndex length = CFStringGetLength(str); + if (length == 0) { + return v8::String::Empty(isolate); + } + + // An ASCII pointer is handed back only when every code unit is < 0x80, so the + // code unit count doubles as the byte count. + if (const char* ascii = CFStringGetCStringPtr(str, kCFStringEncodingASCII)) { + return v8::String::NewFromOneByte(isolate, reinterpret_cast(ascii), + v8::NewStringType::kNormal, (int)length) + .ToLocalChecked(); + } + + if (const UniChar* utf16 = CFStringGetCharactersPtr(str)) { + return v8::String::NewFromTwoByte(isolate, reinterpret_cast(utf16), + v8::NewStringType::kNormal, (int)length) + .ToLocalChecked(); + } + + // Tagged pointers and some bridged strings expose neither buffer, so the + // contents have to be copied out. The narrow attempt writes at most `length` + // bytes, which always fits the UTF-16-sized buffer. + constexpr CFIndex kStackUnits = 256; + uint16_t stackBuffer[kStackUnits]; + std::vector heapBuffer; + uint16_t* buffer = stackBuffer; + if (length > kStackUnits) { + heapBuffer.resize((size_t)length); + buffer = heapBuffer.data(); + } + + CFRange range = CFRangeMake(0, length); + CFIndex usedLength = 0; + if (CFStringGetBytes(str, range, kCFStringEncodingASCII, 0, false, + reinterpret_cast(buffer), length, &usedLength) == length) { + return v8::String::NewFromOneByte(isolate, reinterpret_cast(buffer), + v8::NewStringType::kNormal, (int)length) + .ToLocalChecked(); + } + + CFStringGetCharacters(str, range, reinterpret_cast(buffer)); + return v8::String::NewFromTwoByte(isolate, buffer, v8::NewStringType::kNormal, (int)length) .ToLocalChecked(); } #endif diff --git a/TestRunner/app/tests/Marshalling/NSStringTests.js b/TestRunner/app/tests/Marshalling/NSStringTests.js index 935b5441..da757e0d 100644 --- a/TestRunner/app/tests/Marshalling/NSStringTests.js +++ b/TestRunner/app/tests/Marshalling/NSStringTests.js @@ -39,6 +39,46 @@ describe(module.id, function () { expect(instance.valueForKey('x')).toBe(data); }); + function roundTrip(value) { + var JSObject = NSObject.extend({ + 'x': function () { + return this._x; + }, 'setX:': function (x) { + this._x = x; + } + }, { + exposedMethods: { + x: { returns: NSString }, + 'setX:': { returns: interop.types.void, params: [NSString] } + } + }); + + var instance = JSObject.alloc().init(); + instance.setValueForKey(value, 'x'); + return instance.valueForKey('x'); + } + + it("Marshals NSString with a lone surrogate", function () { + const data = 'a' + String.fromCharCode(0xD800) + 'b'; + expect(roundTrip(data)).toBe(data); + }); + + it("Marshals NSString across both storage widths", function () { + // ASCII, Latin-1 and non-BMP exercise the one-byte, two-byte and + // surrogate-pair paths respectively. + expect(roundTrip('plain ascii')).toBe('plain ascii'); + expect(roundTrip('café naïve')).toBe('café naïve'); + expect(roundTrip('你好世界')).toBe('你好世界'); + expect(roundTrip('emoji 👋🏽 here')).toBe('emoji 👋🏽 here'); + }); + + it("Marshals NSString longer than the conversion stack buffer", function () { + const ascii = 'a'.repeat(1000); + const wide = '𝄞'.repeat(1000); + expect(roundTrip(ascii)).toBe(ascii); + expect(roundTrip(wide)).toBe(wide); + }); + it("String", function () { var str = NSString.string(); expect(str.isKindOfClass(NSString)).toBe(true); From dd6c67581badd1d24bb7b9156c0b9ff23a576eec Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 13 Aug 2026 15:14:11 -0300 Subject: [PATCH 2/6] perf(strings): read V8 strings without the Utf8Value detour tns::ToString ran every conversion through v8::String::Utf8Value, which heap-allocates a buffer and UTF-8-encodes into it, and then copied that buffer a second time into the returned std::string. Two allocations and two passes for a value that is almost always an ASCII identifier. Read the string's native buffer with ValueView instead. A one-byte V8 string that is pure ASCII already is its own UTF-8 encoding, so it is copied straight into the result; everything else encodes once with WriteUtf8V2 writing directly into the string's storage. Since libc++ keeps strings up to 22 characters inline, property names, selectors and class names now convert without touching the heap at all. The unwrap step moves into ToStringLocal so the same handling is shared with the other text conversions. It keeps Utf8Value's TryCatch: a throwing toString() stayed swallowed rather than becoming a pending exception in the 100-odd call sites that never expected one. ConcurrentMap, ArgConverter::GetMeta and InlineFunctions::IsGlobalFunction took their keys by value, so the global property interceptor copied the name twice more on every miss. They take const references now. --- NativeScript/runtime/ArgConverter.h | 2 +- NativeScript/runtime/ArgConverter.mm | 2 +- NativeScript/runtime/ConcurrentMap.h | 60 ++++++++++++------------ NativeScript/runtime/Helpers.h | 52 ++++++++++++++++---- NativeScript/runtime/InlineFunctions.cpp | 2 +- NativeScript/runtime/InlineFunctions.h | 2 +- 6 files changed, 78 insertions(+), 42 deletions(-) diff --git a/NativeScript/runtime/ArgConverter.h b/NativeScript/runtime/ArgConverter.h index 62bc7bb4..5e918109 100644 --- a/NativeScript/runtime/ArgConverter.h +++ b/NativeScript/runtime/ArgConverter.h @@ -66,7 +66,7 @@ class ArgConverter { // recycled the address and give it a foreign prototype. static std::shared_ptr> FindCachedInstance( v8::Isolate* isolate, const std::shared_ptr& cache, id target); - static const Meta* GetMeta(std::string name); + static const Meta* GetMeta(const std::string& name); static const ProtocolMeta* FindProtocolMeta(Protocol* protocol); static void MethodCallback(ffi_cif* cif, void* retValue, void** argValues, void* userData); diff --git a/NativeScript/runtime/ArgConverter.mm b/NativeScript/runtime/ArgConverter.mm index 0615fef9..72edbd38 100644 --- a/NativeScript/runtime/ArgConverter.mm +++ b/NativeScript/runtime/ArgConverter.mm @@ -1013,7 +1013,7 @@ return nullptr; } -const Meta* ArgConverter::GetMeta(std::string name) { +const Meta* ArgConverter::GetMeta(const std::string& name) { bool found; const Meta* meta = Caches::Metadata->Get(name, found); if (meta != nullptr || found) { diff --git a/NativeScript/runtime/ConcurrentMap.h b/NativeScript/runtime/ConcurrentMap.h index 91fb9842..353cb687 100644 --- a/NativeScript/runtime/ConcurrentMap.h +++ b/NativeScript/runtime/ConcurrentMap.h @@ -9,36 +9,36 @@ namespace tns { template class ConcurrentMap { public: - inline void Insert(TKey& key, TValue value) { - std::lock_guard writerLock(this->containerMutex_); - this->container_[key] = value; - } - - inline TValue Get(TKey& key) { - bool found; - return this->Get(key, found); - } - - inline TValue Get(TKey& key, bool& found) { - std::lock_guard writerLock(this->containerMutex_); - auto it = this->container_.find(key); - found = it != this->container_.end(); - if (found) { - return it->second; - } - return nullptr; - } - - inline bool ContainsKey(TKey& key) { - std::lock_guard writerLock(this->containerMutex_); - auto it = this->container_.find(key); - return it != this->container_.end(); - } - - inline void Remove(TKey& key) { - std::lock_guard writerLock(this->containerMutex_); - this->container_.erase(key); - } + inline void Insert(const TKey& key, TValue value) { + std::lock_guard writerLock(this->containerMutex_); + this->container_[key] = value; + } + + inline TValue Get(const TKey& key) { + bool found; + return this->Get(key, found); + } + + inline TValue Get(const TKey& key, bool& found) { + std::lock_guard writerLock(this->containerMutex_); + auto it = this->container_.find(key); + found = it != this->container_.end(); + if (found) { + return it->second; + } + return nullptr; + } + + inline bool ContainsKey(const TKey& key) { + std::lock_guard writerLock(this->containerMutex_); + auto it = this->container_.find(key); + return it != this->container_.end(); + } + + inline void Remove(const TKey& key) { + std::lock_guard writerLock(this->containerMutex_); + this->container_.erase(key); + } inline void ForEach(const std::function& func) { std::lock_guard writerLock(this->containerMutex_); diff --git a/NativeScript/runtime/Helpers.h b/NativeScript/runtime/Helpers.h index 30414190..3de59bf4 100644 --- a/NativeScript/runtime/Helpers.h +++ b/NativeScript/runtime/Helpers.h @@ -98,24 +98,60 @@ inline v8::Local ToV8String(v8::Isolate* isolate, const NSString* va .ToLocalChecked(); } #endif -inline std::string ToString(v8::Isolate* isolate, const v8::Local& value) { +// Unwraps a value to the v8::String the text conversions below read from. +// A throwing toString() is swallowed rather than left pending, which is the +// contract v8::String::Utf8Value offered and callers were written against. +inline bool ToStringLocal(v8::Isolate* isolate, const v8::Local& value, + v8::Local& out) { if (value.IsEmpty()) { - return std::string(); + return false; + } + + if (value->IsString()) { + out = value.As(); + return true; } if (value->IsStringObject()) { - v8::Local obj = value.As()->ValueOf(); - return tns::ToString(isolate, obj); + out = value.As()->ValueOf(); + return true; } - v8::String::Utf8Value result(isolate, value); + v8::TryCatch tc(isolate); + return value->ToString(isolate->GetCurrentContext()).ToLocal(&out); +} - const char* val = *result; - if (val == nullptr) { +inline std::string ToString(v8::Isolate* isolate, const v8::Local& value) { + v8::Local str; + if (!ToStringLocal(isolate, value, str)) { return std::string(); } - return std::string(*result, result.length()); + { + v8::String::ValueView view(isolate, str); + if (view.is_one_byte()) { + const uint8_t* data = view.data8(); + uint32_t length = view.length(); + uint32_t i = 0; + while (i < length && data[i] < 0x80) { + i++; + } + // Pure ASCII already is its own UTF-8 encoding, so it can be copied + // straight out. A one-byte string with a high byte is Latin-1 and still + // needs widening, which the encode below handles. + if (i == length) { + return std::string(reinterpret_cast(data), length); + } + } + } + + size_t length = str->Utf8LengthV2(isolate); + std::string result(length, '\0'); + if (length > 0) { + str->WriteUtf8V2(isolate, result.data(), length, v8::String::WriteFlags::kReplaceInvalidUtf8); + } + + return result; } #ifdef __OBJC__ diff --git a/NativeScript/runtime/InlineFunctions.cpp b/NativeScript/runtime/InlineFunctions.cpp index abce884f..37682cdd 100644 --- a/NativeScript/runtime/InlineFunctions.cpp +++ b/NativeScript/runtime/InlineFunctions.cpp @@ -17,7 +17,7 @@ void InlineFunctions::Init(Local context) { } } -bool InlineFunctions::IsGlobalFunction(std::string name) { +bool InlineFunctions::IsGlobalFunction(const std::string& name) { return name == "CGPointMake" || name == "CGRectMake" || name == "CGSizeMake" || name == "UIEdgeInsetsMake" || name == "NSMakeRange" || name == "__decorate" || name == "__param" || diff --git a/NativeScript/runtime/InlineFunctions.h b/NativeScript/runtime/InlineFunctions.h index 87259e97..5255b13c 100644 --- a/NativeScript/runtime/InlineFunctions.h +++ b/NativeScript/runtime/InlineFunctions.h @@ -8,7 +8,7 @@ namespace tns { class InlineFunctions { public: static void Init(v8::Local context); - static bool IsGlobalFunction(std::string name); + static bool IsGlobalFunction(const std::string& name); }; } From 1ed657165ab778e953f8f2e14570ecfdef8f1261 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 13 Aug 2026 15:17:36 -0300 Subject: [PATCH 3/6] perf(strings): stop building temporaries on the way into V8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToV8String had overloads for std::string, (const char*, int) and NSString*, but none for a bare const char*. Every string literal, every c_str(), every jsName() therefore picked the std::string overload and paid a strlen, a malloc, a copy and a free purely to select it. Adding the missing overload removes that from roughly 270 literal and 37 pointer call sites without touching any of them. Selectors no longer detour through Foundation. The argument path built a std::string, wrapped it in an NSString and handed it to NSSelectorFromString, which just calls sel_registerName on the UTF-8 bytes; it now calls sel_registerName directly. The return path asked NSStringFromSelector for an NSString only to read -UTF8String back out, where sel_getName already returns the bytes. The unichar argument path went V8 -> UTF-8 -> std::u16string -> std::vector to read a single code unit, by way of the deprecated std::codecvt_utf8_utf16. It reads that code unit off ValueView now, which retires tns::ToVector and the last use of . The unichar return path was also wrong. It packed the code unit into a char[2] with no room for a terminator and passed it to std::string, which read past the array, and for values above 127 it wrote the high byte first, so a unichar of 0xE9 produced a leading NUL and the call returned "" instead of "é". A unichar is one UTF-16 code unit and is now handed to V8 as one. The existing coverage only passed ASCII 'i', so this went unnoticed; the new case covers 'é' and '✓'. --- NativeScript/runtime/Helpers.h | 9 ++++- NativeScript/runtime/Helpers.mm | 14 ------- NativeScript/runtime/Interop.mm | 40 +++++++++---------- .../tests/Marshalling/Primitives/Instance.js | 6 +++ 4 files changed, 32 insertions(+), 37 deletions(-) diff --git a/NativeScript/runtime/Helpers.h b/NativeScript/runtime/Helpers.h index 3de59bf4..b9ac983e 100644 --- a/NativeScript/runtime/Helpers.h +++ b/NativeScript/runtime/Helpers.h @@ -41,6 +41,13 @@ inline v8::Local ToV8String(v8::Isolate* isolate, const char* value, return v8::String::NewFromUtf8(isolate, value, v8::NewStringType::kNormal, length) .ToLocalChecked(); } + +// Without this overload a bare `const char*` — every string literal, every +// c_str(), every jsName() — picks the std::string one and pays for a temporary +// just to reach V8. +inline v8::Local ToV8String(v8::Isolate* isolate, const char* value) { + return v8::String::NewFromUtf8(isolate, value).ToLocalChecked(); +} #ifdef __OBJC__ // Both sides store text as either 8-bit or UTF-16, never UTF-8, so the buffer is // handed to V8 in whichever width CFString already holds. Going through @@ -225,8 +232,6 @@ inline bool ToBool(const v8::Local& value) { return result; } -std::vector ToVector(const std::string& value); - bool Exists(const char* fullPath); v8::Local ReadModule(v8::Isolate* isolate, const std::string& filePath); const char* ReadText(const std::string& filePath, long& length, bool& isNew); diff --git a/NativeScript/runtime/Helpers.mm b/NativeScript/runtime/Helpers.mm index 270f352f..d5a1cf0b 100644 --- a/NativeScript/runtime/Helpers.mm +++ b/NativeScript/runtime/Helpers.mm @@ -8,9 +8,7 @@ #include #include #include -#include #include -#include #include #include "Caches.h" #include "ErrorEvents.h" @@ -54,18 +52,6 @@ return std::u16string((const char16_t*)result.data16(), result.length()); } -std::vector tns::ToVector(const std::string& value) { -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - // FIXME: std::codecvt_utf8_utf16 is deprecated - std::wstring_convert, char16_t> convert; - std::u16string value16 = convert.from_bytes(value); - - const uint16_t* begin = reinterpret_cast(value16.data()); - const uint16_t* end = reinterpret_cast(value16.data() + value16.size()); - std::vector vector(begin, end); - return vector; -} - bool tns::Exists(const char* fullPath) { struct stat statbuf; mode_t mode = S_IFDIR | S_IFREG; diff --git a/NativeScript/runtime/Interop.mm b/NativeScript/runtime/Interop.mm index fb29bbf2..175d8420 100644 --- a/NativeScript/runtime/Interop.mm +++ b/NativeScript/runtime/Interop.mm @@ -262,9 +262,7 @@ inline bool isBool() { } else if (argHelper.isString() && typeEncoding->type == BinaryTypeEncodingType::SelectorEncoding) { std::string str = tns::ToString(isolate, arg); - NSString* selStr = [NSString stringWithUTF8String:str.c_str()]; - SEL selector = NSSelectorFromString(selStr); - Interop::SetValue(dest, selector); + Interop::SetValue(dest, sel_registerName(str.c_str())); } else if (typeEncoding->type == BinaryTypeEncodingType::CStringEncoding) { if (arg->IsString()) { const char* value = nullptr; @@ -314,12 +312,21 @@ inline bool isBool() { } } else if (argHelper.isString() && typeEncoding->type == BinaryTypeEncodingType::UnicharEncoding) { - v8::String::Utf8Value utf8Value(isolate, arg); - std::vector vector = tns::ToVector(*utf8Value); - if (vector.size() > 1) { + Local str; + uint32_t length = 0; + unichar c = 0; + if (tns::ToStringLocal(isolate, arg, str)) { + // Scoped so the view's no-GC window closes before anything can throw. + v8::String::ValueView view(isolate, str); + length = view.length(); + if (length == 1) { + c = view.is_one_byte() ? view.data8()[0] : view.data16()[0]; + } + } + + if (length > 1) { throw NativeScriptException("Only one character string can be converted to unichar."); } - unichar c = (vector.size() == 0) ? 0 : vector[0]; Interop::SetValue(dest, c); } else if (argHelper.isString() && (typeEncoding->type == BinaryTypeEncodingType::InterfaceDeclarationReference || @@ -980,8 +987,7 @@ inline bool isBool() { return Null(isolate); } - NSString* selStr = NSStringFromSelector(result); - return tns::ToV8String(isolate, [selStr UTF8String]); + return tns::ToV8String(isolate, sel_getName(result)); } if (typeEncoding->type == BinaryTypeEncodingType::ProtocolEncoding) { @@ -1355,18 +1361,10 @@ inline bool isBool() { } if (type == BinaryTypeEncodingType::UnicharEncoding) { - unichar result = call->GetResult(); - char chars[2]; - - if (result > 127) { - chars[0] = (result >> 8) & (1 << 8) - 1; - chars[1] = result & (1 << 8) - 1; - } else { - chars[0] = result; - chars[1] = 0; - } - - return tns::ToV8String(isolate, chars); + // A unichar is a single UTF-16 code unit, so it goes over as one. + uint16_t result = call->GetResult(); + return v8::String::NewFromTwoByte(isolate, &result, v8::NewStringType::kNormal, 1) + .ToLocalChecked(); } if (type == BinaryTypeEncodingType::UCharEncoding) { diff --git a/TestRunner/app/tests/Marshalling/Primitives/Instance.js b/TestRunner/app/tests/Marshalling/Primitives/Instance.js index 47b4eace..42a5e671 100644 --- a/TestRunner/app/tests/Marshalling/Primitives/Instance.js +++ b/TestRunner/app/tests/Marshalling/Primitives/Instance.js @@ -231,6 +231,12 @@ describe(module.id, function () { // Both outcomes are acceptable depending on build configuration expect(threw === true || threw === false).toBe(true); }); + + it("InstanceMethodWithNonAsciiUnichar", function () { + var instance = TNSPrimitives.alloc().init(); + expect(instance.methodWithUnichar('é')).toBe('é'); + expect(instance.methodWithUnichar('✓')).toBe('✓'); + }); it("InstanceMethodWithNSNumber1", function () { var result = TNSPrimitives.alloc().init().methodWithNSNumber(0); From 0e935971c4cede84e7da061cddc767c073151c6b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 13 Aug 2026 15:20:42 -0300 Subject: [PATCH 4/6] perf(strings): drop the per-call class name copies on the invoke path Every Obj-C instance method call from JS allocated a std::string for the receiver's class name purely to probe Caches::ClassPrototypes, and class names routinely exceed the inline buffer a std::string can hold (UITableViewController is 21 characters). Giving that map a transparent hash and equality lets object_getClassName's const char* probe it directly. MethodCallback copied item->className_ on every invocation even though only the class-side branch replaces it, and InvokeMethod then took the name by value and immediately called c_str() on it. The common path now reads the cached name in place. --- NativeScript/runtime/ArgConverter.mm | 6 ++---- NativeScript/runtime/Caches.h | 21 ++++++++++++++++++++- NativeScript/runtime/MetadataBuilder.h | 2 +- NativeScript/runtime/MetadataBuilder.mm | 15 ++++++++++----- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/NativeScript/runtime/ArgConverter.mm b/NativeScript/runtime/ArgConverter.mm index 72edbd38..56f1ab2a 100644 --- a/NativeScript/runtime/ArgConverter.mm +++ b/NativeScript/runtime/ArgConverter.mm @@ -61,9 +61,8 @@ ObjCDataWrapper* objcWrapper = static_cast(wrapper); target = objcWrapper->Data(); - std::string className = object_getClassName(target); auto cache = Caches::Get(isolate); - auto it = cache->ClassPrototypes.find(className); + auto it = cache->ClassPrototypes.find(std::string_view(object_getClassName(target))); // For extended classes we will call the base method callSuper = isMethodCallback && it != cache->ClassPrototypes.end(); } else { @@ -910,8 +909,7 @@ Class klass = [target class]; const Meta* meta = FindMeta(klass, typeEncoding); if (meta != nullptr) { - std::string className = object_getClassName(target); - auto it = cache->ClassPrototypes.find(className); + auto it = cache->ClassPrototypes.find(std::string_view(object_getClassName(target))); if (it != cache->ClassPrototypes.end()) { // for debugging rlv cell handling: // NSString* message = [NSString stringWithFormat:@"ArgConverter::CreateJsWrapper FindMeta: diff --git a/NativeScript/runtime/Caches.h b/NativeScript/runtime/Caches.h index 7aa2065b..6fbc0b10 100644 --- a/NativeScript/runtime/Caches.h +++ b/NativeScript/runtime/Caches.h @@ -2,6 +2,7 @@ #define Caches_h #include +#include #include #include "Common.h" @@ -16,6 +17,23 @@ struct ObjectWeakCallbackState; class PromiseRejectionTracker; class IsolateTracked; +// Declaring both halves transparent lets robin_hood probe a map keyed by +// std::string with a string_view, so callers holding a const char* from the +// Obj-C runtime do not have to allocate one just to look up. +struct TransparentStringHash { + using is_transparent = void; + size_t operator()(std::string_view key) const { + return robin_hood::hash_bytes(key.data(), key.size()); + } +}; + +struct TransparentStringEqual { + using is_transparent = void; + bool operator()(std::string_view lhs, std::string_view rhs) const { + return lhs == rhs; + } +}; + struct pair_hash { template std::size_t operator()(const std::pair& pair) const { @@ -101,7 +119,8 @@ class Caches { std::unique_ptr>> Prototypes; robin_hood::unordered_map>> + std::unique_ptr>, + TransparentStringHash, TransparentStringEqual> ClassPrototypes; robin_hood::unordered_map< const BaseClassMeta*, diff --git a/NativeScript/runtime/MetadataBuilder.h b/NativeScript/runtime/MetadataBuilder.h index 36f275a6..c997d242 100644 --- a/NativeScript/runtime/MetadataBuilder.h +++ b/NativeScript/runtime/MetadataBuilder.h @@ -69,7 +69,7 @@ class MetadataBuilder { const MethodMeta* meta, v8::Local receiver, V8Args& args, - std::string containingClass, + const std::string& containingClass, bool isMethodCallback); static void RegisterAllocMethod( v8::Isolate* isolate, v8::Local ctorFuncTemplate, diff --git a/NativeScript/runtime/MetadataBuilder.mm b/NativeScript/runtime/MetadataBuilder.mm index 43d2a3eb..01813dce 100644 --- a/NativeScript/runtime/MetadataBuilder.mm +++ b/NativeScript/runtime/MetadataBuilder.mm @@ -764,21 +764,25 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta bool instanceMethod = info.This()->InternalFieldCount() > 0; V8FunctionCallbackArgs args(info); - std::string className = item->className_; + // Only the class-side call rewrites the name, so the common path reads + // item->className_ in place rather than copying it on every invocation. + const std::string* className = &item->className_; + std::string classWrapperName; Local thiz = info.This(); if (thiz->IsFunction()) { if (BaseDataWrapper* wrapper = tns::GetValue(isolate, thiz)) { ObjCClassWrapper* classWrapper = static_cast(wrapper); - className = class_getName(classWrapper->Klass()); + classWrapperName = class_getName(classWrapper->Klass()); + className = &classWrapperName; } } Local context = isolate->GetCurrentContext(); Local result = instanceMethod - ? MetadataBuilder::InvokeMethod(context, item->meta_, info.This(), args, className, true) - : MetadataBuilder::InvokeMethod(context, item->meta_, Local(), args, className, + ? MetadataBuilder::InvokeMethod(context, item->meta_, info.This(), args, *className, true) + : MetadataBuilder::InvokeMethod(context, item->meta_, Local(), args, *className, true); if (!result.IsEmpty()) { @@ -967,7 +971,8 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta Local MetadataBuilder::InvokeMethod(Local context, const MethodMeta* meta, Local receiver, V8Args& args, - std::string containingClass, bool isMethodCallback) { + const std::string& containingClass, + bool isMethodCallback) { Class klass = objc_getClass(containingClass.c_str()); // TODO: Find out if the isMethodCallback property can be determined based on a // UITableViewController.prototype.viewDidLoad.call(this) or super.viewDidLoad() call From 1922d1a9f64eedf5efa3e928f6e21eebba47055c Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 13 Aug 2026 15:35:30 -0300 Subject: [PATCH 5/6] fix(strings): stop routing NSString exception text through UTF-8 Several sites handed [x UTF8String] to ToV8String. -UTF8String returns nil for any string holding a lone surrogate, so those calls strlen a null pointer rather than producing a string. Passing the NSString straight to the overload that reads CFString's own buffer is nil-safe, keeps lone surrogates intact and skips the encode entirely. Covers the NSException name and reason, NSError's localizedDescription and domain, NSString elements yielded by fast enumeration, and the description shown for a native object. The sites that build a std::string from -UTF8String (Interop.mm:1677 and 1718, ModuleInternal, DevFlags) have the same hazard but need a separate NSString-to-std::string helper; they are left as they are. --- NativeScript/runtime/Interop.mm | 11 ++++------- NativeScript/runtime/MetadataBuilder.mm | 3 +-- NativeScript/runtime/SymbolIterator.mm | 2 +- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/NativeScript/runtime/Interop.mm b/NativeScript/runtime/Interop.mm index 175d8420..1372855f 100644 --- a/NativeScript/runtime/Interop.mm +++ b/NativeScript/runtime/Interop.mm @@ -1679,15 +1679,12 @@ inline bool isBool() { Local jsErrObj = jsErrVal.As(); if (nsName != nil) { - jsErrObj - ->Set(context, tns::ToV8String(isolate, "name"), - tns::ToV8String(isolate, [nsName UTF8String])) + jsErrObj->Set(context, tns::ToV8String(isolate, "name"), tns::ToV8String(isolate, nsName)) .FromMaybe(false); } if (nsReason != nil) { jsErrObj - ->Set(context, tns::ToV8String(isolate, "message"), - tns::ToV8String(isolate, [nsReason UTF8String])) + ->Set(context, tns::ToV8String(isolate, "message"), tns::ToV8String(isolate, nsReason)) .FromMaybe(false); } @@ -1711,7 +1708,7 @@ inline bool isBool() { Local context = isolate->GetCurrentContext(); Local jsErrVal = - Exception::Error(tns::ToV8String(isolate, [[error localizedDescription] UTF8String])); + Exception::Error(tns::ToV8String(isolate, [error localizedDescription])); if (jsErrVal.IsEmpty() || !jsErrVal->IsObject()) { // Fallback: if for some reason we cannot create an Error object, throw a generic // NativeScriptException @@ -1728,7 +1725,7 @@ inline bool isBool() { if (error.domain) { jsErrObj ->Set(context, tns::ToV8String(isolate, "domain"), - tns::ToV8String(isolate, [error.domain UTF8String])) + tns::ToV8String(isolate, error.domain)) .FromMaybe(false); } else { jsErrObj->Set(context, tns::ToV8String(isolate, "domain"), Null(isolate)).FromMaybe(false); diff --git a/NativeScript/runtime/MetadataBuilder.mm b/NativeScript/runtime/MetadataBuilder.mm index 01813dce..ce9f6417 100644 --- a/NativeScript/runtime/MetadataBuilder.mm +++ b/NativeScript/runtime/MetadataBuilder.mm @@ -482,8 +482,7 @@ NamedPropertyHandlerConfiguration config(nullptr, MetadataBuilder::SwizzledInsta return; } - std::string description = [[target description] UTF8String]; - Local returnValue = tns::ToV8String(info.GetIsolate(), description); + Local returnValue = tns::ToV8String(info.GetIsolate(), [target description]); info.GetReturnValue().Set(returnValue); } diff --git a/NativeScript/runtime/SymbolIterator.mm b/NativeScript/runtime/SymbolIterator.mm index 2e713b0a..15fdec02 100644 --- a/NativeScript/runtime/SymbolIterator.mm +++ b/NativeScript/runtime/SymbolIterator.mm @@ -86,7 +86,7 @@ if ([item isKindOfClass:[NSNumber class]]) { val = Number::New(isolate, [item doubleValue]); } else if ([item isKindOfClass:[NSString class]]) { - val = tns::ToV8String(isolate, [item UTF8String]); + val = tns::ToV8String(isolate, (NSString*)item); } else { auto wrapper = new ObjCDataWrapper(item); val = ArgConverter::CreateJsWrapper(context, wrapper, Local()); From 9c2628b2c29fe333b37f557d0d61d07426885a5d Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 13 Aug 2026 15:47:42 -0300 Subject: [PATCH 6/6] fix(strings): stop building std::string from -UTF8String in error paths The NSException path constructed its message from [nsReason UTF8String] before reaching the conversions that handle nil, so an exception whose reason held a lone surrogate built a std::string from a null pointer and crashed while reporting another failure. The NSException and NSError fallbacks did the same with -description and -localizedDescription. Add a ToString overload that encodes through V8, which substitutes U+FFFD for the unpaired half rather than giving back nil. An unpaired surrogate has no UTF-8 spelling, so a replacement character is the only answer a std::string can carry. Interop.mm no longer calls -UTF8String anywhere. --- NativeScript/runtime/Helpers.h | 7 +++++++ NativeScript/runtime/Interop.mm | 10 +++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/NativeScript/runtime/Helpers.h b/NativeScript/runtime/Helpers.h index b9ac983e..c1ba13c6 100644 --- a/NativeScript/runtime/Helpers.h +++ b/NativeScript/runtime/Helpers.h @@ -162,6 +162,13 @@ inline std::string ToString(v8::Isolate* isolate, const v8::Local& va } #ifdef __OBJC__ +// Encodes via V8 rather than -UTF8String, which returns nil for a string holding +// a lone surrogate — leaving callers to construct a std::string from nullptr. +// The unpaired half becomes U+FFFD, since it has no UTF-8 spelling. +inline std::string ToString(v8::Isolate* isolate, const NSString* value) { + return tns::ToString(isolate, tns::ToV8String(isolate, value)); +} + inline NSString* ToNSString(const std::string& v) { return [[[NSString alloc] initWithBytes:v.c_str() length:v.length() encoding:NSUTF8StringEncoding] S_AUTORELEASE]; diff --git a/NativeScript/runtime/Interop.mm b/NativeScript/runtime/Interop.mm index 1372855f..0edf81ed 100644 --- a/NativeScript/runtime/Interop.mm +++ b/NativeScript/runtime/Interop.mm @@ -1668,13 +1668,13 @@ inline bool isBool() { NSString* nsName = [e name]; NSString* nsReason = [e reason]; - std::string message = - nsReason ? [nsReason UTF8String] : (nsName ? [nsName UTF8String] : "NSException"); + Local messageV8 = tns::ToV8String(isolate, nsReason ?: (nsName ?: @"NSException")); + std::string message = tns::ToString(isolate, messageV8); - Local jsErrVal = Exception::Error(tns::ToV8String(isolate, message)); + Local jsErrVal = Exception::Error(messageV8); if (jsErrVal.IsEmpty() || !jsErrVal->IsObject()) { // Fallback: keep the description-only behavior if Error creation fails. - throw NativeScriptException([[e description] UTF8String]); + throw NativeScriptException(tns::ToString(isolate, [e description])); } Local jsErrObj = jsErrVal.As(); @@ -1712,7 +1712,7 @@ inline bool isBool() { if (jsErrVal.IsEmpty() || !jsErrVal->IsObject()) { // Fallback: if for some reason we cannot create an Error object, throw a generic // NativeScriptException - throw NativeScriptException([[error localizedDescription] UTF8String]); + throw NativeScriptException(tns::ToString(isolate, [error localizedDescription])); } Local jsErrObj = jsErrVal.As();