diff --git a/NativeScript/ffi/jni/jsi/jni/LRUCache.h b/NativeScript/ffi/jni/jsi/jni/LRUCache.h index 7b829c3dd..afceeb132 100644 --- a/NativeScript/ffi/jni/jsi/jni/LRUCache.h +++ b/NativeScript/ffi/jni/jsi/jni/LRUCache.h @@ -100,6 +100,16 @@ class LRUCache { } } + void clear() { + if (m_evictCallback != nullptr) { + for (auto& entry : m_key_to_value) { + m_evictCallback(entry.second.first, m_state); + } + } + m_key_to_value.clear(); + m_key_tracker.clear(); + } + void update(const key_type& key, const value_type& value) { jweak ref = m_loadCallback(key, m_state); insert(key, ref); diff --git a/NativeScript/ffi/jni/jsi/objectmanager/ObjectManager.cpp b/NativeScript/ffi/jni/jsi/objectmanager/ObjectManager.cpp index 394a45446..81743ed09 100644 --- a/NativeScript/ffi/jni/jsi/objectmanager/ObjectManager.cpp +++ b/NativeScript/ffi/jni/jsi/objectmanager/ObjectManager.cpp @@ -85,6 +85,10 @@ void ObjectManager::OnDisposeRuntime() { m_idToObject.clear(); m_jsObjectCtor = JsFunction(); + // The cache's destructor does not delete its weak global refs, and the + // process-wide weak reference table is finite. + m_cache.clear(); + // The host-object proxies are owned by the *engine*, and each holds an owned // handle to the instance it wraps. Nothing above reaches them: they are only // destroyed when the engine tears its heap down, at which point diff --git a/NativeScript/ffi/objc/shared/bridge/HostObject.mm b/NativeScript/ffi/objc/shared/bridge/HostObject.mm index d16a91f96..d895e718e 100644 --- a/NativeScript/ffi/objc/shared/bridge/HostObject.mm +++ b/NativeScript/ffi/objc/shared/bridge/HostObject.mm @@ -23,6 +23,11 @@ inline bool InstallNativeApiLazyGlobal( explicit NativeApiHostObject(std::shared_ptr bridge) : bridge_(std::move(bridge)) {} + // Each bridge has exactly one API object and it lives on its runtime's + // global, so this runs as that runtime is finalized. Running earlier would + // only leak the Values the bridge drops afterwards. + ~NativeApiHostObject() override { bridge_->markRuntimeTornDown(); } + Value get(Runtime& runtime, const PropNameID& name) override { std::string property = name.utf8(runtime); if (property == "runtime") { diff --git a/NativeScript/ffi/objc/shared/bridge/Install.mm b/NativeScript/ffi/objc/shared/bridge/Install.mm index 611747cf8..cc695d816 100644 --- a/NativeScript/ffi/objc/shared/bridge/Install.mm +++ b/NativeScript/ffi/objc/shared/bridge/Install.mm @@ -1699,6 +1699,24 @@ function nameSet(values) { var protocolNameSet = nameSet(protocolNameList); var enumNameSet = nameSet(enumNameList); + // Only full C constant names (NSTextAlignmentCenter, kCLAuthorizationStatus...) + // become globals. The enum object also carries short member names (Center, + // CloudKit's Zone), which as globals shadow what libraries feature-detect. + function isEnumMemberGlobalName(enumName, memberName) { + if (/^-?\d+$/.test(memberName)) { + return false; + } + if (/^k[A-Z]/.test(memberName)) { + return true; + } + var match = /^[A-Z]+(?=[A-Z][a-z])/.exec(enumName); + var prefix = match ? match[0] : ''; + return prefix.length >= 2 && + memberName.length > prefix.length && + memberName.indexOf(prefix) === 0 && + /[A-Z]/.test(memberName.charAt(prefix.length)); + } + function resolveNativeApiEnum(enumName) { return (api.getEnum && api.getEnum(enumName)) || api[enumName]; } @@ -1759,7 +1777,7 @@ function resolveNativeApiEnum(enumName) { return; } Object.keys(enumValue).forEach(function(memberName) { - if (/^-?\d+$/.test(memberName)) { + if (!isEnumMemberGlobalName(name, memberName)) { return; } defineLazyGlobal(memberName, function() { diff --git a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm index 5baecf609..6b5727d03 100644 --- a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm +++ b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm @@ -540,11 +540,33 @@ explicit NativeApiBridge(const NativeApiConfig& config) } ~NativeApiBridge() { + if (runtimeTornDown_.load(std::memory_order_acquire)) { + abandonRuntimeValues(roundTripValues_); + abandonRuntimeValues(roundTripCacheFramesByThread_); + abandonRuntimeValues(recentRoundTripValues_); +#ifdef TARGET_ENGINE_HERMES + abandonRuntimeValues(roundTripRootCache_); +#endif + abandonRuntimeValues(classValues_); + abandonRuntimeValues(classPrototypes_); + abandonRuntimeValues(pointerValues_); + abandonRuntimeValues(objectExpandos_); + abandonRuntimeValues(retainedLifetimes_); + } if (selfDl_ != nullptr) { dlclose(selfDl_); } } + // Called when the runtime this bridge was installed into is torn down. Host + // objects can be shared into another runtime (react-native-worklets does so + // for values crossing runOnUI) and finalized there after this runtime is + // gone; releasing a Value whose runtime is destroyed is a use-after-free, so + // from here on every Value the bridge drops is leaked instead. + void markRuntimeTornDown() { + runtimeTornDown_.store(true, std::memory_order_release); + } + MDMetadataReader* metadata() const { return metadata_.get(); } void* selfDl() const { return selfDl_; } @@ -708,7 +730,7 @@ Value findRoundTripValue(Runtime& runtime, const void* native, ? validationKey : (nativeIsObject ? nativeObjectClassKey(native) : 0); struct RoundTripCacheEntry { - const NativeApiBridge* bridge = nullptr; + uint64_t bridgeId = 0; uintptr_t key = 0; uint64_t generation = 0; std::weak_ptr value; @@ -722,7 +744,7 @@ Value findRoundTripValue(Runtime& runtime, const void* native, const size_t firstSlot = (key >> 4) & 3; for (size_t i = 0; i < 4; i++) { RoundTripCacheEntry& entry = cache[(firstSlot + i) & 3]; - if (entry.bridge == this && entry.key == key && + if (entry.bridgeId == instanceId_ && entry.key == key && entry.generation == generation) { if (entry.validationKey != expectedValidationKey) { break; @@ -777,13 +799,13 @@ Value findRoundTripValue(Runtime& runtime, const void* native, } if (entry == nullptr) { cache[firstSlot] = RoundTripCacheEntry{ - this, key, generation, {}, true, false, expectedValidationKey}; + instanceId_, key, generation, {}, true, false, expectedValidationKey}; return Value::undefined(); } storedValue = entry->value; cachedStringLike = entry->stringLikeNative; cache[firstSlot] = RoundTripCacheEntry{ - this, key, generation, storedValue, false, cachedStringLike, + instanceId_, key, generation, storedValue, false, cachedStringLike, entry->validationKey}; } if (stringLikeNative != nullptr) { @@ -842,6 +864,7 @@ void forgetRoundTripValue(const void* native) { eraseRoundTripKeyFromScopedCaches(key, releaseAfterUnlock); roundTripValuesGeneration_.fetch_add(1, std::memory_order_release); } + abandonIfRuntimeTornDown(releaseAfterUnlock); } uint64_t roundTripValuesGeneration() const { @@ -973,7 +996,7 @@ Value findObjectExpando(Runtime& runtime, const void* native, return Value::undefined(); } struct ObjectExpandoCacheEntry { - const NativeApiBridge* bridge = nullptr; + uint64_t bridgeId = 0; uintptr_t key = 0; uint64_t generation = 0; std::string property; @@ -988,7 +1011,7 @@ Value findObjectExpando(Runtime& runtime, const void* native, const uint64_t generation = objectExpandosGeneration_.load(std::memory_order_acquire); for (auto& entry : cache) { - if (entry.bridge == this && entry.key == key && + if (entry.bridgeId == instanceId_ && entry.key == key && entry.generation == generation && entry.property == property) { if (entry.miss) { return Value::undefined(); @@ -1004,17 +1027,17 @@ Value findObjectExpando(Runtime& runtime, const void* native, const size_t slot = nextSlot++ & 7; if (objectIt == objectExpandos_.end()) { cache[slot] = - ObjectExpandoCacheEntry{this, key, generation, property, {}, true}; + ObjectExpandoCacheEntry{instanceId_, key, generation, property, {}, true}; return Value::undefined(); } auto propertyIt = objectIt->second.find(property); if (propertyIt == objectIt->second.end() || propertyIt->second == nullptr) { cache[slot] = - ObjectExpandoCacheEntry{this, key, generation, property, {}, true}; + ObjectExpandoCacheEntry{instanceId_, key, generation, property, {}, true}; return Value::undefined(); } cache[slot] = ObjectExpandoCacheEntry{ - this, key, generation, property, propertyIt->second, false}; + instanceId_, key, generation, property, propertyIt->second, false}; return Value(runtime, *propertyIt->second); } @@ -1023,8 +1046,11 @@ void forgetObjectExpandos(const void* native) { return; } auto key = normalizeRuntimePointer(reinterpret_cast(native)); - objectExpandos_.erase( - key); + auto it = objectExpandos_.find(key); + if (it != objectExpandos_.end()) { + abandonIfRuntimeTornDown(it->second); + objectExpandos_.erase(it); + } objectExpandosGeneration_.fetch_add(1, std::memory_order_release); } @@ -1045,7 +1071,7 @@ void forgetObjectExpandos(const void* native) { return nullptr; } struct PropertyGetterCacheEntry { - const NativeApiBridge* bridge = nullptr; + uint64_t bridgeId = 0; Class cls = Nil; uint64_t generation = 0; std::string property; @@ -1058,7 +1084,7 @@ void forgetObjectExpandos(const void* native) { const uint64_t generation = propertyGetterCacheGeneration_.load(std::memory_order_acquire); for (auto& entry : cache) { - if (entry.bridge == this && entry.cls == cls && + if (entry.bridgeId == instanceId_ && entry.cls == cls && entry.generation == generation && entry.property == property) { return entry.miss ? nullptr : entry.getter; } @@ -1068,17 +1094,17 @@ void forgetObjectExpandos(const void* native) { const size_t slot = nextSlot++ & 7; if (classIt == propertyGetterCache_.end()) { cache[slot] = PropertyGetterCacheEntry{ - this, cls, generation, property, nullptr, true}; + instanceId_, cls, generation, property, nullptr, true}; return nullptr; } auto propIt = classIt->second.find(property); if (propIt == classIt->second.end()) { cache[slot] = PropertyGetterCacheEntry{ - this, cls, generation, property, nullptr, true}; + instanceId_, cls, generation, property, nullptr, true}; return nullptr; } cache[slot] = - PropertyGetterCacheEntry{this, cls, generation, property, + PropertyGetterCacheEntry{instanceId_, cls, generation, property, &propIt->second, false}; return &propIt->second; } @@ -1111,7 +1137,11 @@ void forgetPointerValue(const void* native) { if (native == nullptr) { return; } - pointerValues_.erase(reinterpret_cast(native)); + auto it = pointerValues_.find(reinterpret_cast(native)); + if (it != pointerValues_.end()) { + abandonIfRuntimeTornDown(it->second); + pointerValues_.erase(it); + } } const NativeApiSymbol* findConstant(const std::string& name) const { @@ -2101,6 +2131,47 @@ static void appendSurfaceMember( return members; } + template + void abandonIfRuntimeTornDown(T& values) { + if (runtimeTornDown_.load(std::memory_order_acquire)) { + abandonRuntimeValues(values); + } + } + + // Moves each Value out into a deliberately leaked allocation so destroying + // the container never reaches the runtime. Leak the Value, not its + // shared_ptr: the thread_local lookup caches hold weak_ptrs and rely on them + // expiring once this bridge is gone. + static void abandonRuntimeValues(std::shared_ptr& value) { + if (value != nullptr) { + new Value(std::move(*value)); + } + } + + static void abandonRuntimeValues(std::shared_ptr& lifetime) { + if (lifetime != nullptr) { + new std::shared_ptr(std::move(lifetime)); + } + } + + static void abandonRuntimeValues(NativeApiRoundTripValue& entry) { + abandonRuntimeValues(entry.value); + } + + template + static void abandonRuntimeValues(std::unordered_map& values) { + for (auto& entry : values) { + abandonRuntimeValues(entry.second); + } + } + + template + static void abandonRuntimeValues(std::vector& values) { + for (auto& value : values) { + abandonRuntimeValues(value); + } + } + std::unique_ptr metadata_; void* selfDl_ = nullptr; std::unordered_map symbolsByName_; @@ -2133,6 +2204,14 @@ static void appendSurfaceMember( objectExpandos_; std::unordered_map objectExpandoOwnerCounts_; std::atomic objectExpandosGeneration_{1}; + std::atomic runtimeTornDown_{false}; + // Identifies this bridge in the thread_local lookup caches. Its address + // cannot: a bridge allocated where a destroyed one lived would match that + // one's entries, and the property-getter cache holds raw pointers into it. + const uint64_t instanceId_ = [] { + static std::atomic next{1}; + return next.fetch_add(1, std::memory_order_relaxed); + }(); std::unordered_map> propertyGetterCache_; std::atomic propertyGetterCacheGeneration_{1}; diff --git a/NativeScript/runtime/android/jsi/EngineHost.cpp b/NativeScript/runtime/android/jsi/EngineHost.cpp index 7b54b8d28..5cab1d051 100644 --- a/NativeScript/runtime/android/jsi/EngineHost.cpp +++ b/NativeScript/runtime/android/jsi/EngineHost.cpp @@ -17,7 +17,8 @@ #endif #if defined(TARGET_ENGINE_JSC) -// JSGlobalContextSetUnhandledRejectionCallback lives in this private JSC header. +// JSGlobalContextSetUnhandledRejectionCallback lives in this private JSC +// header. #include #endif @@ -26,258 +27,278 @@ #include "mimalloc.h" #ifdef __QJS_NG__ -static void *js_mi_calloc(void *, size_t count, size_t size) { - return mi_calloc(count, size); +static void* js_mi_calloc(void*, size_t count, size_t size) { + return mi_calloc(count, size); } -static void *js_mi_malloc(void *, size_t size) { return mi_malloc(size); } +static void* js_mi_malloc(void*, size_t size) { return mi_malloc(size); } -static void js_mi_free(void *, void *ptr) { - if (ptr != nullptr) mi_free(ptr); +static void js_mi_free(void*, void* ptr) { + if (ptr != nullptr) mi_free(ptr); } -static void *js_mi_realloc(void *, void *ptr, size_t size) { - return mi_realloc(ptr, size); +static void* js_mi_realloc(void*, void* ptr, size_t size) { + return mi_realloc(ptr, size); } -static const JSMallocFunctions kMiMallocFunctions = { - js_mi_calloc, js_mi_malloc, js_mi_free, js_mi_realloc, - mi_malloc_usable_size}; +static const JSMallocFunctions kMiMallocFunctions = {js_mi_calloc, js_mi_malloc, + js_mi_free, js_mi_realloc, + mi_malloc_usable_size}; #else #define NS_MALLOC_OVERHEAD 8 -static void *js_mi_malloc(JSMallocState *s, size_t size) { - if (s->malloc_size + size > s->malloc_limit) return nullptr; - void *ptr = mi_malloc(size); - if (ptr == nullptr) return nullptr; - s->malloc_count++; - s->malloc_size += mi_malloc_usable_size(ptr) + NS_MALLOC_OVERHEAD; - return ptr; +static void* js_mi_malloc(JSMallocState* s, size_t size) { + if (s->malloc_size + size > s->malloc_limit) return nullptr; + void* ptr = mi_malloc(size); + if (ptr == nullptr) return nullptr; + s->malloc_count++; + s->malloc_size += mi_malloc_usable_size(ptr) + NS_MALLOC_OVERHEAD; + return ptr; } -static void js_mi_free(JSMallocState *s, void *ptr) { - if (ptr == nullptr) return; - s->malloc_count--; - s->malloc_size -= mi_malloc_usable_size(ptr) + NS_MALLOC_OVERHEAD; - mi_free(ptr); +static void js_mi_free(JSMallocState* s, void* ptr) { + if (ptr == nullptr) return; + s->malloc_count--; + s->malloc_size -= mi_malloc_usable_size(ptr) + NS_MALLOC_OVERHEAD; + mi_free(ptr); } -static void *js_mi_realloc(JSMallocState *s, void *ptr, size_t size) { - if (ptr == nullptr) { - if (size == 0) return nullptr; - return js_mi_malloc(s, size); - } - const size_t oldSize = mi_malloc_usable_size(ptr); - if (size == 0) { - s->malloc_count--; - s->malloc_size -= oldSize + NS_MALLOC_OVERHEAD; - mi_free(ptr); - return nullptr; - } - if (s->malloc_size + size - oldSize > s->malloc_limit) return nullptr; - ptr = mi_realloc(ptr, size); - if (ptr == nullptr) return nullptr; - s->malloc_size += mi_malloc_usable_size(ptr) - oldSize; - return ptr; +static void* js_mi_realloc(JSMallocState* s, void* ptr, size_t size) { + if (ptr == nullptr) { + if (size == 0) return nullptr; + return js_mi_malloc(s, size); + } + const size_t oldSize = mi_malloc_usable_size(ptr); + if (size == 0) { + s->malloc_count--; + s->malloc_size -= oldSize + NS_MALLOC_OVERHEAD; + mi_free(ptr); + return nullptr; + } + if (s->malloc_size + size - oldSize > s->malloc_limit) return nullptr; + ptr = mi_realloc(ptr, size); + if (ptr == nullptr) return nullptr; + s->malloc_size += mi_malloc_usable_size(ptr) - oldSize; + return ptr; } -static const JSMallocFunctions kMiMallocFunctions = {js_mi_malloc, js_mi_free, - js_mi_realloc, - mi_malloc_usable_size}; +static const JSMallocFunctions kMiMallocFunctions = { + js_mi_malloc, js_mi_free, js_mi_realloc, mi_malloc_usable_size}; #endif // __QJS_NG__ #endif // USE_MIMALLOC -// `globalThis.gc`. This QuickJS is patched locally so that js_weakref_constructor -// pins its target via JS_KeepWeakRefTargetAlive at construction; the pin is only -// released by JS_ClearWeakRefKeepAlives, so without the clear nothing weakly -// referenced is ever collectable. -static JSValue nsRunGC(JSContext *ctx, JSValueConst, int, JSValueConst *) { - JSRuntime *rt = JS_GetRuntime(ctx); - JS_ClearWeakRefKeepAlives(rt); - JS_RunGC(rt); - return JS_UNDEFINED; +// `globalThis.gc`. This QuickJS is patched locally so that +// js_weakref_constructor pins its target via JS_KeepWeakRefTargetAlive at +// construction; the pin is only released by JS_ClearWeakRefKeepAlives, so +// without the clear nothing weakly referenced is ever collectable. +static JSValue nsRunGC(JSContext* ctx, JSValueConst, int, JSValueConst*) { + JSRuntime* rt = JS_GetRuntime(ctx); + JS_ClearWeakRefKeepAlives(rt); + JS_RunGC(rt); + return JS_UNDEFINED; } #endif // TARGET_ENGINE_QUICKJS using namespace tns; -void EngineHost::SetFlags(const char *flags) { - if (flags == nullptr || *flags == '\0') return; +void EngineHost::SetFlags(const char* flags) { + if (flags == nullptr || *flags == '\0') return; #if defined(TARGET_ENGINE_V8) - v8::V8::SetFlagsFromString(flags); + v8::V8::SetFlagsFromString(flags); #endif } #if defined(NS_JSI_HOST_RUNTIME) -std::shared_ptr EngineHost::Adopt(::facebook::jsi::Runtime &runtime) { - // Not make_shared: the constructor is private. - std::shared_ptr host(new EngineHost()); - // The non-owning engine::Runtime constructor (jsi/hermes/HermesRuntime.h). - // There is no RuntimeConfig to mirror from the Create() path below: the - // embedder already built and configured the VM, and re-deciding any of it - // here would either be ignored or corrupt a runtime other code is using. - host->m_runtime = std::make_unique(runtime); - return host; +std::shared_ptr EngineHost::Adopt( + ::facebook::jsi::Runtime& runtime) { + // Not make_shared: the constructor is private. + std::shared_ptr host(new EngineHost()); + // The non-owning engine::Runtime constructor (jsi/hermes/HermesRuntime.h). + // There is no RuntimeConfig to mirror from the Create() path below: the + // embedder already built and configured the VM, and re-deciding any of it + // here would either be ignored or corrupt a runtime other code is using. + host->m_runtime = std::make_unique(runtime); + return host; } #else std::shared_ptr EngineHost::Create() { - // Not make_shared: the constructor is private. - std::shared_ptr host(new EngineHost()); + // Not make_shared: the constructor is private. + std::shared_ptr host(new EngineHost()); #if defined(TARGET_ENGINE_V8) - // Process-global, and exactly once: V8 aborts if the platform is initialised - // twice, and every Worker creates a runtime. The isolate below stays - // per-runtime, which is what actually isolates a worker. - static std::once_flag platformOnce; - static std::unique_ptr platform; - std::call_once(platformOnce, [] { - v8::V8::InitializeICUDefaultLocation(nullptr); - platform = v8::platform::NewDefaultPlatform(); - v8::V8::InitializePlatform(platform.get()); - v8::V8::Initialize(); - }); - - v8::Isolate::CreateParams params; - host->m_allocator.reset(v8::ArrayBuffer::Allocator::NewDefaultAllocator()); - params.array_buffer_allocator = host->m_allocator.get(); - host->m_isolate = v8::Isolate::New(params); - - { - v8::Isolate::Scope isolateScope(host->m_isolate); - v8::HandleScope handleScope(host->m_isolate); - v8::Local context = v8::Context::New(host->m_isolate); - host->m_context.Reset(host->m_isolate, context); - host->m_runtime = std::make_unique(host->m_isolate, context); - } + // Process-global, and exactly once: V8 aborts if the platform is initialised + // twice, and every Worker creates a runtime. The isolate below stays + // per-runtime, which is what actually isolates a worker. + static std::once_flag platformOnce; + static std::unique_ptr platform; + std::call_once(platformOnce, [] { + v8::V8::InitializeICUDefaultLocation(nullptr); + platform = v8::platform::NewDefaultPlatform(); + v8::V8::InitializePlatform(platform.get()); + v8::V8::Initialize(); + }); + + v8::Isolate::CreateParams params; + host->m_allocator.reset(v8::ArrayBuffer::Allocator::NewDefaultAllocator()); + params.array_buffer_allocator = host->m_allocator.get(); + host->m_isolate = v8::Isolate::New(params); + + { + v8::Isolate::Scope isolateScope(host->m_isolate); + v8::HandleScope handleScope(host->m_isolate); + v8::Local context = v8::Context::New(host->m_isolate); + host->m_context.Reset(host->m_isolate, context); + host->m_runtime = + std::make_unique(host->m_isolate, context); + } #elif defined(TARGET_ENGINE_HERMES) - // Hermes has no isolate, locker or handle-scope model: the runtime object is - // the whole of it. withMicrotaskQueue is what ExecutePendingJobs depends on. - { - ::hermes::vm::RuntimeConfig config = - ::hermes::vm::RuntimeConfig::Builder() - .withMicrotaskQueue(true) - .withES6BlockScoping(true) - .withEnableAsyncGenerators(true) - .withAsyncBreakCheckInEval(true) - .build(); - host->m_threadSafe = facebook::hermes::makeThreadSafeHermesRuntime(config); - host->m_runtime = - std::make_unique(host->m_threadSafe->getUnsafeRuntime()); - } + // Hermes has no isolate, locker or handle-scope model: the runtime object is + // the whole of it. withMicrotaskQueue is what ExecutePendingJobs depends on. + { + ::hermes::vm::RuntimeConfig config = ::hermes::vm::RuntimeConfig::Builder() + .withMicrotaskQueue(true) + .withES6BlockScoping(true) + .withEnableAsyncGenerators(true) + .withAsyncBreakCheckInEval(true) + .build(); + host->m_threadSafe = facebook::hermes::makeThreadSafeHermesRuntime(config); + host->m_runtime = std::make_unique( + host->m_threadSafe->getUnsafeRuntime()); + } #elif defined(TARGET_ENGINE_QUICKJS) - { + { #ifdef USE_MIMALLOC - host->m_jsRuntime = JS_NewRuntime2(&kMiMallocFunctions, nullptr); + host->m_jsRuntime = JS_NewRuntime2(&kMiMallocFunctions, nullptr); #else - host->m_jsRuntime = JS_NewRuntime(); + host->m_jsRuntime = JS_NewRuntime(); #endif - if (host->m_jsRuntime == nullptr) return nullptr; - // 0 disables the stack-depth guard, which also removes any need to - // re-record the stack top per thread; the runtime is entered from Java - // threads. - JS_SetMaxStackSize(host->m_jsRuntime, 0); - - host->m_jsContext = JS_NewContext(host->m_jsRuntime); - if (host->m_jsContext == nullptr) { - JS_FreeRuntime(host->m_jsRuntime); - host->m_jsRuntime = nullptr; - return nullptr; - } - - JSValue global = JS_GetGlobalObject(host->m_jsContext); - JS_SetPropertyStr(host->m_jsContext, global, "gc", - JS_NewCFunction(host->m_jsContext, nsRunGC, "gc", 0)); - JS_FreeValue(host->m_jsContext, global); - - host->m_runtime = std::make_unique(host->m_jsContext); + if (host->m_jsRuntime == nullptr) return nullptr; + // 0 disables the stack-depth guard, which also removes any need to + // re-record the stack top per thread; the runtime is entered from Java + // threads. + JS_SetMaxStackSize(host->m_jsRuntime, 0); + + host->m_jsContext = JS_NewContext(host->m_jsRuntime); + if (host->m_jsContext == nullptr) { + JS_FreeRuntime(host->m_jsRuntime); + host->m_jsRuntime = nullptr; + return nullptr; } + + JSValue global = JS_GetGlobalObject(host->m_jsContext); + JS_SetPropertyStr(host->m_jsContext, global, "gc", + JS_NewCFunction(host->m_jsContext, nsRunGC, "gc", 0)); + JS_FreeValue(host->m_jsContext, global); + + host->m_runtime = std::make_unique(host->m_jsContext); + } #elif defined(TARGET_ENGINE_JSC) - // JSC has no isolate or locker to manage: JSGlobalContextRef is the whole of - // it, and it takes its own JSLock internally on every API call. - { - host->m_jscContext = JSGlobalContextCreateInGroup(nullptr, nullptr); - if (host->m_jscContext == nullptr) return nullptr; - host->m_runtime = std::make_unique(host->m_jscContext); - - engine::Runtime &rt = *host->m_runtime; - JSGlobalContextRef context = host->m_jscContext; - - // `globalThis.gc`. JSC has no --expose_gc equivalent, so SetFlags cannot - // supply it the way the V8 path does, and mainpage.js needs it. - engine::Function gcFunction = engine::Function::createFromHostFunction( - rt, engine::PropNameID::forAscii(rt, "gc"), 0, - [context](engine::Runtime &, const engine::Value &, const engine::Value *, - size_t) -> engine::Value { - JSGarbageCollect(context); - return engine::Value::undefined(); - }); - rt.global().setProperty(rt, "gc", gcFunction); - - // Unhandled promise rejections, routed to the JS-side tracker that - // ts_helpers.js installs. JSC only reports the "unhandled" event, never a - // later retraction. - engine::Function rejectionCallback = engine::Function::createFromHostFunction( - rt, engine::PropNameID::forAscii(rt, "onUnhandledRejection"), 2, - [](engine::Runtime &runtime, const engine::Value &, const engine::Value *args, - size_t count) -> engine::Value { - engine::Object global = runtime.global(); - engine::Value tracker = - global.getProperty(runtime, "onUnhandledPromiseRejectionTracker"); - if (tracker.isObject() && tracker.asObject(runtime).isFunction(runtime)) { - tracker.asObject(runtime).asFunction(runtime).callWithThis( - runtime, global, args, count); - } - return engine::Value::undefined(); - }); - // JSC keeps the callback alive: it is stored on, and marked by, the global - // object. - JSValueRef rejectionException = nullptr; - JSGlobalContextSetUnhandledRejectionCallback(context, rejectionCallback.local(rt), - &rejectionException); - } + // JSC has no isolate or locker to manage: JSGlobalContextRef is the whole of + // it, and it takes its own JSLock internally on every API call. + { + host->m_jscContext = JSGlobalContextCreateInGroup(nullptr, nullptr); + if (host->m_jscContext == nullptr) return nullptr; + host->m_runtime = std::make_unique(host->m_jscContext); + + engine::Runtime& rt = *host->m_runtime; + JSGlobalContextRef context = host->m_jscContext; + + // `globalThis.gc`. JSC has no --expose_gc equivalent, so SetFlags cannot + // supply it the way the V8 path does, and mainpage.js needs it. + engine::Function gcFunction = engine::Function::createFromHostFunction( + rt, engine::PropNameID::forAscii(rt, "gc"), 0, + [context](engine::Runtime&, const engine::Value&, const engine::Value*, + size_t) -> engine::Value { + JSGarbageCollect(context); + return engine::Value::undefined(); + }); + rt.global().setProperty(rt, "gc", gcFunction); + + // Unhandled promise rejections, routed to the JS-side tracker that + // ts_helpers.js installs. JSC only reports the "unhandled" event, never a + // later retraction. + engine::Function rejectionCallback = + engine::Function::createFromHostFunction( + rt, engine::PropNameID::forAscii(rt, "onUnhandledRejection"), 2, + [](engine::Runtime& runtime, const engine::Value&, + const engine::Value* args, size_t count) -> engine::Value { + engine::Object global = runtime.global(); + engine::Value tracker = global.getProperty( + runtime, "onUnhandledPromiseRejectionTracker"); + if (tracker.isObject() && + tracker.asObject(runtime).isFunction(runtime)) { + tracker.asObject(runtime).asFunction(runtime).callWithThis( + runtime, global, args, count); + } + return engine::Value::undefined(); + }); + // JSC keeps the callback alive: it is stored on, and marked by, the global + // object. + JSValueRef rejectionException = nullptr; + JSGlobalContextSetUnhandledRejectionCallback( + context, rejectionCallback.local(rt), &rejectionException); + } #endif - if (host->m_runtime == nullptr) return nullptr; - return host; + if (host->m_runtime == nullptr) return nullptr; + return host; } #endif // NS_JSI_HOST_RUNTIME void EngineHost::Lock() { - m_mutex.lock(); - m_lockDepth++; + m_mutex.lock(); + m_owner.store(std::this_thread::get_id(), std::memory_order_relaxed); + m_lockDepth++; #if defined(TARGET_ENGINE_HERMES) && !defined(NS_JSI_HOST_RUNTIME) - // Registers the calling thread with the VM. Hermes records per-thread stack - // bounds for its overflow guard, so entering from a Java thread without this - // faults rather than raising a RangeError. - if (m_threadSafe != nullptr) m_threadSafe->lock(); + // Registers the calling thread with the VM. Hermes records per-thread stack + // bounds for its overflow guard, so entering from a Java thread without this + // faults rather than raising a RangeError. + if (m_threadSafe != nullptr) m_threadSafe->lock(); #endif } void EngineHost::Unlock() { - if (m_lockDepth > 0) m_lockDepth--; + if (m_lockDepth > 0) m_lockDepth--; + if (m_lockDepth == 0) + m_owner.store(std::thread::id(), std::memory_order_relaxed); #if defined(TARGET_ENGINE_HERMES) && !defined(NS_JSI_HOST_RUNTIME) - if (m_threadSafe != nullptr) m_threadSafe->unlock(); + if (m_threadSafe != nullptr) m_threadSafe->unlock(); #endif - m_mutex.unlock(); + m_mutex.unlock(); +} + +int EngineHost::ReleaseAll() { + // Another thread's ownership is published under the mutex, so a stale read + // here can only ever be some other id, never this thread's. + if (m_owner.load(std::memory_order_relaxed) != std::this_thread::get_id()) + return 0; + int depth = m_lockDepth; + for (int i = 0; i < depth; i++) Unlock(); + return depth; +} + +void EngineHost::Reacquire(int depth) { + for (int i = 0; i < depth; i++) Lock(); } void EngineHost::ExecutePendingJobs() { - if (m_runtime == nullptr) return; + if (m_runtime == nullptr) return; #if defined(NS_JSI_HOST_RUNTIME) - // The embedder owns the queue and decides when it runs; see ~JSScope. + // The embedder owns the queue and decides when it runs; see ~JSScope. #else - m_runtime->drainMicrotasks(); + m_runtime->drainMicrotasks(); #endif } -engine::Value EngineHost::ExecuteScript(const std::string &source, const std::string &sourceURL) { - auto buffer = std::make_shared(source); - return m_runtime->evaluateJavaScript(buffer, sourceURL); +engine::Value EngineHost::ExecuteScript(const std::string& source, + const std::string& sourceURL) { + auto buffer = std::make_shared(source); + return m_runtime->evaluateJavaScript(buffer, sourceURL); } namespace { @@ -291,164 +312,166 @@ constexpr size_t kContainerHeaderLen = 12; // Peeks the first 8 bytes of `path` and compares them with `magic8`. Cheap // enough to run for every module: a bytecode build hits it on the fast path and // a source build pays one fopen/fread of 8 bytes. -bool HasBytecodeMagic(const std::string &path, const void *magic8) { - uint8_t head[8]; - FILE *fp = fopen(path.c_str(), "rb"); - if (fp == nullptr) return false; - size_t read = fread(head, 1, sizeof(head), fp); - fclose(fp); - return read == sizeof(head) && memcmp(head, magic8, sizeof(head)) == 0; +bool HasBytecodeMagic(const std::string& path, const void* magic8) { + uint8_t head[8]; + FILE* fp = fopen(path.c_str(), "rb"); + if (fp == nullptr) return false; + size_t read = fread(head, 1, sizeof(head), fp); + fclose(fp); + return read == sizeof(head) && memcmp(head, magic8, sizeof(head)) == 0; } } // namespace -bool EngineHost::ExecuteBytecodeFile(const std::string &path, const std::string &sourceURL, - engine::Value &result) { - if (m_runtime == nullptr) return false; +bool EngineHost::ExecuteBytecodeFile(const std::string& path, + const std::string& sourceURL, + engine::Value& result) { + if (m_runtime == nullptr) return false; #if defined(TARGET_ENGINE_QUICKJS) #ifdef __QJS_NG__ - static const char *kMagic = "NSBCNGS"; // 7 chars + NUL = 8-byte magic + static const char* kMagic = "NSBCNGS"; // 7 chars + NUL = 8-byte magic #else - static const char *kMagic = "NSBCQJS"; + static const char* kMagic = "NSBCQJS"; #endif - if (!HasBytecodeMagic(path, kMagic)) return false; - - int length = 0; - auto data = static_cast(File::ReadBinary(path, length)); - if (data == nullptr) return false; - if (static_cast(length) <= kContainerHeaderLen) { - delete[] data; - return false; - } + if (!HasBytecodeMagic(path, kMagic)) return false; - JSContext *ctx = m_runtime->context(); - // JS_ReadObject copies what it needs, so the buffer can be freed right after. - JSValue funObj = JS_ReadObject(ctx, data + kContainerHeaderLen, - static_cast(length) - kContainerHeaderLen, - JS_READ_OBJ_BYTECODE); + int length = 0; + auto data = static_cast(File::ReadBinary(path, length)); + if (data == nullptr) return false; + if (static_cast(length) <= kContainerHeaderLen) { delete[] data; - - // Past the magic check the file IS bytecode, so a failure here must be - // surfaced rather than silently retried as source -- retrying would compile - // the binary blob and produce a nonsense error much further away. - if (JS_IsException(funObj)) { - throw engine::quickjsengine::caughtError(*m_runtime, "QuickJS bytecode could not be read."); - } - - // JS_EvalFunction consumes funObj. - JSValue evalResult = JS_EvalFunction(ctx, funObj); - if (JS_IsException(evalResult)) { - throw engine::quickjsengine::caughtError(*m_runtime, "QuickJS bytecode evaluation failed."); - } - - result = engine::Value(*m_runtime, evalResult); - JS_FreeValue(ctx, evalResult); - return true; + return false; + } + + JSContext* ctx = m_runtime->context(); + // JS_ReadObject copies what it needs, so the buffer can be freed right after. + JSValue funObj = JS_ReadObject( + ctx, data + kContainerHeaderLen, + static_cast(length) - kContainerHeaderLen, JS_READ_OBJ_BYTECODE); + delete[] data; + + // Past the magic check the file IS bytecode, so a failure here must be + // surfaced rather than silently retried as source -- retrying would compile + // the binary blob and produce a nonsense error much further away. + if (JS_IsException(funObj)) { + throw engine::quickjsengine::caughtError( + *m_runtime, "QuickJS bytecode could not be read."); + } + + // JS_EvalFunction consumes funObj. + JSValue evalResult = JS_EvalFunction(ctx, funObj); + if (JS_IsException(evalResult)) { + throw engine::quickjsengine::caughtError( + *m_runtime, "QuickJS bytecode evaluation failed."); + } + + result = engine::Value(*m_runtime, evalResult); + JS_FreeValue(ctx, evalResult); + return true; #elif defined(TARGET_ENGINE_HERMES) - // Hermes bytecode (HBC) magic, first 8 bytes little endian - // (0x1F1903C103BC1FC6). - static const uint8_t kMagic[8] = {0xc6, 0x1f, 0xbc, 0x03, 0xc1, 0x03, 0x19, 0x1f}; - if (!HasBytecodeMagic(path, kMagic)) return false; - - int length = 0; - auto data = static_cast(File::ReadBinary(path, length)); - if (data == nullptr) return false; - - // HermesRuntime::evaluateJavaScript detects an HBC buffer and skips the - // parser, so the same entry point serves source and bytecode. The buffer is - // binary and contains NULs, hence the (ptr, len) string constructor. - auto buffer = std::make_shared( - std::string(reinterpret_cast(data), static_cast(length))); - delete[] data; - - result = m_runtime->evaluateJavaScript(buffer, sourceURL); - return true; + // Hermes bytecode (HBC) magic, first 8 bytes little endian + // (0x1F1903C103BC1FC6). + static const uint8_t kMagic[8] = {0xc6, 0x1f, 0xbc, 0x03, + 0xc1, 0x03, 0x19, 0x1f}; + if (!HasBytecodeMagic(path, kMagic)) return false; + + int length = 0; + auto data = static_cast(File::ReadBinary(path, length)); + if (data == nullptr) return false; + + // HermesRuntime::evaluateJavaScript detects an HBC buffer and skips the + // parser, so the same entry point serves source and bytecode. The buffer is + // binary and contains NULs, hence the (ptr, len) string constructor. + auto buffer = std::make_shared(std::string( + reinterpret_cast(data), static_cast(length))); + delete[] data; + + result = m_runtime->evaluateJavaScript(buffer, sourceURL); + return true; #else - // V8 and JSC have no compile-time bytecode format; both cache compiled code - // at runtime instead, so their release builds ship plain source. - (void) path; - (void) sourceURL; - (void) result; - return false; + // V8 and JSC have no compile-time bytecode format; both cache compiled code + // at runtime instead, so their release builds ship plain source. + (void)path; + (void)sourceURL; + (void)result; + return false; #endif } int64_t EngineHost::AdjustExternalMemory(int64_t changeInBytes) { #if defined(TARGET_ENGINE_V8) - if (m_isolate != nullptr) { - return m_isolate->AdjustAmountOfExternalAllocatedMemory(changeInBytes); - } + if (m_isolate != nullptr) { + return m_isolate->AdjustAmountOfExternalAllocatedMemory(changeInBytes); + } #endif - // Engines with no external-memory accounting report no change; the caller - // uses this as a GC hint, not for correctness. - (void) changeInBytes; - return 0; + // Engines with no external-memory accounting report no change; the caller + // uses this as a GC hint, not for correctness. + (void)changeInBytes; + return 0; } int64_t EngineHost::EnginePtr() const { #if defined(TARGET_ENGINE_V8) - return reinterpret_cast(m_isolate); + return reinterpret_cast(m_isolate); #elif defined(TARGET_ENGINE_QUICKJS) - // The JSRuntime is QuickJS' closest analogue of an isolate: it owns the heap - // and the GC. - return reinterpret_cast(m_jsRuntime); + // The JSRuntime is QuickJS' closest analogue of an isolate: it owns the heap + // and the GC. + return reinterpret_cast(m_jsRuntime); #else - return 0; + return 0; #endif } -const char *EngineHost::EngineVersion() const { +const char* EngineHost::EngineVersion() const { #if defined(TARGET_ENGINE_V8) - return v8::V8::GetVersion(); + return v8::V8::GetVersion(); #elif defined(TARGET_ENGINE_QUICKJS) #ifdef __QJS_NG__ - return "QuickJS-NG"; + return "QuickJS-NG"; #else - return "QuickJS"; + return "QuickJS"; #endif #else - return "unknown"; + return "unknown"; #endif } -void EngineHost::ReleaseEngineState() { - m_runtime.reset(); -} +void EngineHost::ReleaseEngineState() { m_runtime.reset(); } EngineHost::~EngineHost() { - // Under NS_JSI_HOST_RUNTIME this drops the non-owning engine::Runtime - // wrapper only; the jsi::Runtime behind it belongs to the embedder. - m_runtime.reset(); + // Under NS_JSI_HOST_RUNTIME this drops the non-owning engine::Runtime + // wrapper only; the jsi::Runtime behind it belongs to the embedder. + m_runtime.reset(); #if defined(TARGET_ENGINE_HERMES) && !defined(NS_JSI_HOST_RUNTIME) - m_threadSafe.reset(); + m_threadSafe.reset(); #endif #if defined(TARGET_ENGINE_QUICKJS) - if (m_jsContext != nullptr) { - engine::quickjsengine::releaseStateForContext(m_jsContext); - JS_FreeContext(m_jsContext); - m_jsContext = nullptr; - } - if (m_jsRuntime != nullptr) { - JS_FreeRuntime(m_jsRuntime); - m_jsRuntime = nullptr; - } + if (m_jsContext != nullptr) { + engine::quickjsengine::releaseStateForContext(m_jsContext); + JS_FreeContext(m_jsContext); + m_jsContext = nullptr; + } + if (m_jsRuntime != nullptr) { + JS_FreeRuntime(m_jsRuntime); + m_jsRuntime = nullptr; + } #endif #if defined(TARGET_ENGINE_JSC) - if (m_jscContext != nullptr) { - JSGlobalContextRelease(m_jscContext); - m_jscContext = nullptr; - } + if (m_jscContext != nullptr) { + JSGlobalContextRelease(m_jscContext); + m_jscContext = nullptr; + } #endif #if defined(TARGET_ENGINE_V8) - m_context.Reset(); - if (m_isolate != nullptr) { - m_isolate->Dispose(); - m_isolate = nullptr; - } + m_context.Reset(); + if (m_isolate != nullptr) { + m_isolate->Dispose(); + m_isolate = nullptr; + } #endif } -std::shared_ptr tns::JSScope::HostFor(engine::Runtime &rt) { - return tns::Runtime::GetRuntime(rt)->GetEngineHost(); +std::shared_ptr tns::JSScope::HostFor(engine::Runtime& rt) { + return tns::Runtime::GetRuntime(rt)->GetEngineHost(); } diff --git a/NativeScript/runtime/android/jsi/EngineHost.h b/NativeScript/runtime/android/jsi/EngineHost.h index 31dffa0be..7d14df4f1 100644 --- a/NativeScript/runtime/android/jsi/EngineHost.h +++ b/NativeScript/runtime/android/jsi/EngineHost.h @@ -3,19 +3,21 @@ // The jsi tree's replacement for the per-engine napi//jsr.h. // -// The napi runtime reaches the engine through the JSR contract (js_create_runtime, -// js_create_napi_env, js_execute_script, ...) plus that engine's NapiScope. There -// is no Node-API here, so the same two jobs -- own one engine, and enter it from -// the host -- are done by EngineHost and JSScope. +// The napi runtime reaches the engine through the JSR contract +// (js_create_runtime, js_create_napi_env, js_execute_script, ...) plus that +// engine's NapiScope. There is no Node-API here, so the same two jobs -- own +// one engine, and enter it from the host -- are done by EngineHost and JSScope. // // EngineHost is held by shared_ptr and JSScope keeps a strong reference for its // lifetime. That is what makes teardown safe by construction: the VM, and the // recursive mutex the scope holds, cannot be freed while a scope is still // unwinding over them. See Runtime::DisposeWorkerRuntime. +#include #include #include #include +#include #if defined(TARGET_ENGINE_V8) #include "jsi/v8/V8Runtime.h" @@ -49,98 +51,107 @@ namespace engine = ::nativescript::engine; namespace tns { class EngineHost { -public: - // Engine command-line flags (package.json's android.v8Flags), applied before - // any runtime exists. The test app ships "--expose_gc" and mainpage.js does - // `__collect = gc;` on its sixth line, so this is not optional on V8. - static void SetFlags(const char *flags); + public: + // Engine command-line flags (package.json's android.v8Flags), applied before + // any runtime exists. The test app ships "--expose_gc" and mainpage.js does + // `__collect = gc;` on its sixth line, so this is not optional on V8. + static void SetFlags(const char* flags); #if defined(NS_JSI_HOST_RUNTIME) - // Adopts a runtime the embedder owns. The reference must outlive this host: - // React Native tears its runtime down only after the TurboModule that holds - // us has been invalidated, which is what ReleaseEngineState is wired to. - static std::shared_ptr Adopt(::facebook::jsi::Runtime &runtime); + // Adopts a runtime the embedder owns. Nothing may enter the runtime through + // this host once it is gone: React Native tears it down only after the + // TurboModule that holds us has been invalidated, which is what + // Runtime::Detach is wired to. + static std::shared_ptr Adopt(::facebook::jsi::Runtime& runtime); #else - static std::shared_ptr Create(); + static std::shared_ptr Create(); #endif - ~EngineHost(); + ~EngineHost(); - EngineHost(const EngineHost &) = delete; + EngineHost(const EngineHost&) = delete; - EngineHost &operator=(const EngineHost &) = delete; + EngineHost& operator=(const EngineHost&) = delete; - engine::Runtime &GetRuntime() const { return *m_runtime; } + engine::Runtime& GetRuntime() const { return *m_runtime; } - // Alive until ReleaseEngineState runs; callers that can be reached during - // teardown must check. - bool IsAlive() const { return m_runtime != nullptr; } + // Alive until ReleaseEngineState runs; callers that can be reached during + // teardown must check. + bool IsAlive() const { return m_runtime != nullptr; } - void Lock(); + void Lock(); - void Unlock(); + void Unlock(); - int LockDepth() const { return m_lockDepth; } + int LockDepth() const { return m_lockDepth; } - void ExecutePendingJobs(); + // Lets another thread enter while this one waits: releases every level the + // calling thread holds (0 when it holds none) for Reacquire to take back. + int ReleaseAll(); - engine::Value ExecuteScript(const std::string &source, const std::string &sourceURL); + void Reacquire(int depth); - // Runs `path` as ahead-of-time compiled bytecode when the file carries this - // engine's bytecode header, and returns true. Returns false when the file is - // not bytecode for this engine -- including on V8 and JSC, which have no - // compile-time bytecode format at all -- and the caller then compiles the - // source as usual. - // - // This is the jsi tree's counterpart to js_run_bytecode_file. It lives here - // rather than in NativeScript/jsi/ because the container format is a build - // artefact of the Android toolchain (tools/bytecode-compiler), not part of - // the engine abstraction Apple shares. - bool ExecuteBytecodeFile(const std::string &path, const std::string &sourceURL, - engine::Value &result); + void ExecutePendingJobs(); + + engine::Value ExecuteScript(const std::string& source, + const std::string& sourceURL); + + // Runs `path` as ahead-of-time compiled bytecode when the file carries this + // engine's bytecode header, and returns true. Returns false when the file is + // not bytecode for this engine -- including on V8 and JSC, which have no + // compile-time bytecode format at all -- and the caller then compiles the + // source as usual. + // + // This is the jsi tree's counterpart to js_run_bytecode_file. It lives here + // rather than in NativeScript/jsi/ because the container format is a build + // artefact of the Android toolchain (tools/bytecode-compiler), not part of + // the engine abstraction Apple shares. + bool ExecuteBytecodeFile(const std::string& path, + const std::string& sourceURL, engine::Value& result); - int64_t AdjustExternalMemory(int64_t changeInBytes); + int64_t AdjustExternalMemory(int64_t changeInBytes); - int64_t EnginePtr() const; + int64_t EnginePtr() const; - const char *EngineVersion() const; + const char* EngineVersion() const; - // Drops the engine::Runtime while the VM is still standing. Everything that - // holds an engine handle must already be gone; the VM itself is torn down in - // ~EngineHost, which cannot run before the last JSScope has unwound. - void ReleaseEngineState(); + // Drops the engine::Runtime while the VM is still standing. Everything that + // holds an engine handle must already be gone; the VM itself is torn down in + // ~EngineHost, which cannot run before the last JSScope has unwound. + void ReleaseEngineState(); #if defined(TARGET_ENGINE_V8) - v8::Isolate *Isolate() const { return m_isolate; } + v8::Isolate* Isolate() const { return m_isolate; } - v8::Local Context() const { return m_context.Get(m_isolate); } + v8::Local Context() const { return m_context.Get(m_isolate); } #endif -private: - EngineHost() = default; + private: + EngineHost() = default; - std::unique_ptr m_runtime; - std::recursive_mutex m_mutex; - int m_lockDepth = 0; + std::unique_ptr m_runtime; + std::recursive_mutex m_mutex; + int m_lockDepth = 0; + std::atomic m_owner{}; #if defined(TARGET_ENGINE_V8) - v8::Isolate *m_isolate = nullptr; - v8::Global m_context; - std::unique_ptr m_allocator; + v8::Isolate* m_isolate = nullptr; + v8::Global m_context; + std::unique_ptr m_allocator; #endif #if defined(TARGET_ENGINE_HERMES) && !defined(NS_JSI_HOST_RUNTIME) - // Owns the VM. engine::Runtime only borrows getUnsafeRuntime(). - std::unique_ptr m_threadSafe; + // Owns the VM. engine::Runtime only borrows getUnsafeRuntime(). + std::unique_ptr m_threadSafe; #endif #if defined(TARGET_ENGINE_QUICKJS) - JSRuntime *m_jsRuntime = nullptr; - JSContext *m_jsContext = nullptr; + JSRuntime* m_jsRuntime = nullptr; + JSContext* m_jsContext = nullptr; #endif #if defined(TARGET_ENGINE_JSC) - JSGlobalContextRef m_jscContext = nullptr; + JSGlobalContextRef m_jscContext = nullptr; #endif }; @@ -150,74 +161,77 @@ class EngineHost { // across calls, so the engine scopes have to be members of an object that lives // on the caller's stack. class JSScope { -public: - // The napi tree enters JS as `NapiScope scope(env)`; the same call sites here - // only have the engine::Runtime, so this resolves its owning EngineHost. - explicit JSScope(engine::Runtime &rt) : JSScope(HostFor(rt)) {} + public: + // The napi tree enters JS as `NapiScope scope(env)`; the same call sites here + // only have the engine::Runtime, so this resolves its owning EngineHost. + explicit JSScope(engine::Runtime& rt) : JSScope(HostFor(rt)) {} - explicit JSScope(std::shared_ptr host) - : m_host(std::move(host)) + explicit JSScope(std::shared_ptr host) + : m_host(std::move(host)) #if defined(TARGET_ENGINE_V8) - , m_locker(m_host->Isolate()) - , m_isolateScope(m_host->Isolate()) - , m_handleScope(m_host->Isolate()) - , m_context(m_host->Context()) - , m_contextScope(m_context) + , + m_locker(m_host->Isolate()), + m_isolateScope(m_host->Isolate()), + m_handleScope(m_host->Isolate()), + m_context(m_host->Context()), + m_contextScope(m_context) #endif - { - } + { + } - ~JSScope() { + ~JSScope() { #if (defined(TARGET_ENGINE_HERMES) && !defined(NS_JSI_HOST_RUNTIME)) || \ - defined(TARGET_ENGINE_QUICKJS) - // Hermes and QuickJS run Promise jobs from an explicit microtask queue and - // nothing else on Android drains it; V8 and JSC run theirs themselves when - // the host stack unwinds. Draining as the *outermost* scope leaves JS is - // what keeps the ordering the specs assert: a microtask queued during a - // call runs before the next timer callback, which enters through its own - // scope. - // - // NS_JSI_HOST_RUNTIME is excluded because the embedder already drains: - // React Native's RuntimeScheduler runs the queue at the end of each task - // it dispatches. Draining from inside a nested scope of ours would run - // application microtasks at a point RN did not choose. - if (m_host->LockDepth() <= 1 && m_host->IsAlive()) { - m_host->ExecutePendingJobs(); - } -#endif + defined(TARGET_ENGINE_QUICKJS) + // Hermes and QuickJS run Promise jobs from an explicit microtask queue and + // nothing else on Android drains it; V8 and JSC run theirs themselves when + // the host stack unwinds. Draining as the *outermost* scope leaves JS is + // what keeps the ordering the specs assert: a microtask queued during a + // call runs before the next timer callback, which enters through its own + // scope. + // + // NS_JSI_HOST_RUNTIME is excluded because the embedder already drains: + // React Native's RuntimeScheduler runs the queue at the end of each task + // it dispatches. Draining from inside a nested scope of ours would run + // application microtasks at a point RN did not choose. + if (m_host->LockDepth() <= 1 && m_host->IsAlive()) { + m_host->ExecutePendingJobs(); } +#endif + } - JSScope(const JSScope &) = delete; + JSScope(const JSScope&) = delete; - JSScope &operator=(const JSScope &) = delete; + JSScope& operator=(const JSScope&) = delete; -private: - // Defined in EngineHost.cpp, which can include Runtime.h; the header cannot - // (Runtime.h includes this one). - static std::shared_ptr HostFor(engine::Runtime &rt); + private: + // Defined in EngineHost.cpp, which can include Runtime.h; the header cannot + // (Runtime.h includes this one). + static std::shared_ptr HostFor(engine::Runtime& rt); - // Declaration order is load-bearing: members are destroyed in reverse, which - // is the order V8 requires -- the lock is taken before the isolate is - // entered, and the context scope unwinds before the isolate does. - struct HostLock { - std::shared_ptr host; + // Declaration order is load-bearing: members are destroyed in reverse, which + // is the order V8 requires -- the lock is taken before the isolate is + // entered, and the context scope unwinds before the isolate does. + struct HostLock { + std::shared_ptr host; - explicit HostLock(std::shared_ptr h) : host(std::move(h)) { host->Lock(); } + explicit HostLock(std::shared_ptr h) : host(std::move(h)) { + host->Lock(); + } - ~HostLock() { host->Unlock(); } + ~HostLock() { host->Unlock(); } - EngineHost *operator->() const { return host.get(); } - }; + EngineHost* operator->() const { return host.get(); } + }; - HostLock m_host; + HostLock m_host; #if defined(TARGET_ENGINE_V8) - v8::Locker m_locker; - v8::Isolate::Scope m_isolateScope; - v8::HandleScope m_handleScope; - // Held as a member so it outlives the scope that references it; passing - // Context() straight into Context::Scope would bind a temporary. - v8::Local m_context; - v8::Context::Scope m_contextScope; + v8::Locker m_locker; + v8::Isolate::Scope m_isolateScope; + v8::HandleScope m_handleScope; + // Held as a member so it outlives the scope that references it; passing + // Context() straight into Context::Scope would bind a temporary. + v8::Local m_context; + v8::Context::Scope m_contextScope; #endif }; @@ -229,16 +243,17 @@ class JSScope { // installing its globals. namespace engine_util { -inline void SetFunction(engine::Runtime &rt, engine::Object &target, const char *name, - engine::HostFunctionType callback, unsigned int paramCount = 0) { - target.setProperty(rt, name, - engine::Function::createFromHostFunction( - rt, engine::PropNameID::forAscii(rt, name), paramCount, - std::move(callback))); +inline void SetFunction(engine::Runtime& rt, engine::Object& target, + const char* name, engine::HostFunctionType callback, + unsigned int paramCount = 0) { + target.setProperty(rt, name, + engine::Function::createFromHostFunction( + rt, engine::PropNameID::forAscii(rt, name), paramCount, + std::move(callback))); } -} +} // namespace engine_util -} +} // namespace tns -#endif //NS_RUNTIME_ANDROID_JSI_ENGINE_HOST_H +#endif // NS_RUNTIME_ANDROID_JSI_ENGINE_HOST_H diff --git a/NativeScript/runtime/android/jsi/Runtime.cpp b/NativeScript/runtime/android/jsi/Runtime.cpp index 7991157d2..e24cd6d72 100644 --- a/NativeScript/runtime/android/jsi/Runtime.cpp +++ b/NativeScript/runtime/android/jsi/Runtime.cpp @@ -265,6 +265,29 @@ void Runtime::Attach(::facebook::jsi::Runtime &hostRuntime, const std::string &f InitCommon(filesRoot, /*callingDir*/ "", maxLogcatObjectSize, forceLog, /*guest*/ true); } +void Runtime::Detach() { + // Another thread -- the GC listener, typically -- may already hold this + // pointer from a lookup made before DestroyRuntime unregisters it. It then + // blocks on this scope's lock and finds is_destroying set. The lock lives in + // engineHost, which this object owns, so the object must outlive any such + // caller: it is deliberately never deleted. + JSEnterScope + + if (is_destroying) { + return; + } + + DestroyRuntime(); + + // The main looper is the JS thread's, and the next host runtime runs on a + // different thread; letting the next Attach become main rebinds it there. + if (m_isMainThread) { + ReleaseMainLooper(); + s_main_rt = nullptr; + s_mainThreadInitialized = false; + } +} + #endif // NS_JSI_HOST_RUNTIME // Everything both flavours install. @@ -462,8 +485,11 @@ void Runtime::InitCommon(const std::string &filesRoot, const std::string &callin global.setProperty(rt, "global", global); } - if (!s_mainThreadInitialized) { - MetadataNode::BuildMetadata(filesRoot); + if (m_isMainThread) { + if (!s_metadataBuilt) { + MetadataNode::BuildMetadata(filesRoot); + s_metadataBuilt = true; + } } else { // Do not set 'self' accessor to main thread global.setProperty(rt, "self", global); @@ -534,18 +560,27 @@ Runtime::~Runtime() { engineHost.reset(); if (m_isMainThread) { + ReleaseMainLooper(); + } +} + +void Runtime::ReleaseMainLooper() { + if (m_mainLooper != nullptr) { if (m_mainLooper_fd[0] != -1) { ALooper_removeFd(m_mainLooper, m_mainLooper_fd[0]); } ALooper_release(m_mainLooper); + m_mainLooper = nullptr; + } - if (m_mainLooper_fd[0] != -1) { - close(m_mainLooper_fd[0]); - } + if (m_mainLooper_fd[0] != -1) { + close(m_mainLooper_fd[0]); + m_mainLooper_fd[0] = -1; + } - if (m_mainLooper_fd[1] != -1) { - close(m_mainLooper_fd[1]); - } + if (m_mainLooper_fd[1] != -1) { + close(m_mainLooper_fd[1]); + m_mainLooper_fd[1] = -1; } } @@ -914,6 +949,7 @@ jmethodID Runtime::GET_USED_MEMORY_METHOD_ID = nullptr; tns::ConcurrentMap Runtime::id_to_runtime_cache; tns::ConcurrentMap Runtime::rt_to_runtime_cache; bool Runtime::s_mainThreadInitialized = false; +bool Runtime::s_metadataBuilt = false; int Runtime::m_androidVersion = Runtime::GetAndroidVersion(); ALooper *Runtime::m_mainLooper = nullptr; tns::ConcurrentMap Runtime::thread_id_to_rt_cache; diff --git a/NativeScript/runtime/android/jsi/Runtime.h b/NativeScript/runtime/android/jsi/Runtime.h index 3b41c83a4..350bb5274 100644 --- a/NativeScript/runtime/android/jsi/Runtime.h +++ b/NativeScript/runtime/android/jsi/Runtime.h @@ -90,6 +90,12 @@ namespace tns { void Attach(::facebook::jsi::Runtime &hostRuntime, const std::string &filesRoot, bool verboseLoggingEnabled, int maxLogcatObjectSize, bool forceLog); + + // Releases every handle held into the host runtime and unregisters this + // runtime, after which a new Attach can bind to another host runtime. + // The host runtime must still be alive, and its JS thread must not be + // running JS. Idempotent; the object itself is never freed. + void Detach(); #endif jobject GetJavaRuntime() const; @@ -232,6 +238,12 @@ namespace tns { static bool s_mainThreadInitialized; + // Separate from s_mainThreadInitialized, which a guest resets on Detach: + // the MetadataNode caches point into the tree, so it is built only once. + static bool s_metadataBuilt; + + static void ReleaseMainLooper(); + static ALooper *m_mainLooper; static int m_mainLooper_fd[2]; diff --git a/NativeScript/runtime/android/jsi/com_tns_Runtime.cpp b/NativeScript/runtime/android/jsi/com_tns_Runtime.cpp index 20ab03d8f..2f66610ea 100644 --- a/NativeScript/runtime/android/jsi/com_tns_Runtime.cpp +++ b/NativeScript/runtime/android/jsi/com_tns_Runtime.cpp @@ -273,8 +273,13 @@ extern "C" JNIEXPORT jint Java_com_tns_Runtime_generateNewObjectIdLegacy(JNIEnv* // @FastNative ABI: standard JNI signature (registered via RegisterNatives). static jboolean notifyGcFast_impl(JNIEnv* jEnv, jobject obj, jint runtimeId, jintArray object_ids) { - auto runtime = TryGetRuntime(runtimeId); - if (runtime == nullptr) { + // Not TryGetRuntime: the GC listener can deliver a batch for a runtime that + // was disposed while the batch was being collected. Its objects have nothing + // left to release, and throwing would kill the listener's thread. + Runtime* runtime = nullptr; + try { + runtime = Runtime::GetRuntime(runtimeId); + } catch (NativeScriptException&) { return JNI_FALSE; } // The napi version passes false here to skip opening a napi handle scope. diff --git a/metadata-generator/src/TSEmitter/TypeSpec.cpp b/metadata-generator/src/TSEmitter/TypeSpec.cpp index ad65bbb62..3dcdee653 100644 --- a/metadata-generator/src/TSEmitter/TypeSpec.cpp +++ b/metadata-generator/src/TSEmitter/TypeSpec.cpp @@ -66,7 +66,8 @@ std::string TSFile::typeToString(const TypeSpec& type, bool isStatic, bool isRet result += ") => "; result += typeToString(*type.callbackReturn, false, true); if (type.isNullable) { - result += " | null"; + // `() => T | null` would make the return type nullable instead. + result = "(" + result + ") | null"; } break; } diff --git a/packages/objc-node-api/index.d.ts b/packages/objc-node-api/index.d.ts index 021f11abb..f6b1e610f 100644 --- a/packages/objc-node-api/index.d.ts +++ b/packages/objc-node-api/index.d.ts @@ -9,6 +9,27 @@ declare global { class NativeObject implements interop.PointerObject { readonly __brand__: interop.PointerObject["__brand__"]; + + /** + * Registers an Objective-C subclass of this class. Functions in `methods` + * override the same-named members of this class and of + * `options.protocols`; other names are only callable from Objective-C + * when listed in `options.exposedMethods`. The returned class cannot + * itself be extended. + */ + static extend< + // deno-lint-ignore no-explicit-any + This extends abstract new (...args: any) => any, + // Inferred rather than annotated so that names absent from the class + // (protocol and exposed methods) are not excess properties. `& object` + // keeps literals sharing no name with the class from failing the weak + // type check against the all-optional Partial. + Methods extends Partial> & object, + >( + this: This, + methods: Methods & ThisType & Methods>, + options?: objc.ExtendOptions, + ): This; } namespace objc { @@ -25,6 +46,19 @@ declare global { ptr: interop.Pointer, size: number, ): ArrayBuffer; + + export interface ExposedMethod { + returns?: unknown; + params?: unknown[]; + } + + export interface ExtendOptions { + /** Objective-C class name; a numeric suffix is added if it is taken. */ + name?: string; + protocols?: unknown[]; + /** Keyed by method name in `methods`. */ + exposedMethods?: Record; + } } namespace interop { diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 4a38a7a1d..3dd46c8a3 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -1,25 +1,171 @@ # @nativescript/react-native -React Native TurboModule wrapper for the NativeScript Native API JSI bridge on -Hermes. +NativeScript's Native API for React Native: call Objective-C and UIKit on iOS, +and Java and the Android SDK on Android, directly from JavaScript. -The module exposes one small TurboModule whose `init()` method attaches the +The package is one small TurboModule. Its `init()` method attaches the NativeScript Native API host object to `globalThis.__nativeScriptNativeApi` and -installs lazy NativeScript-style globals for classes and C functions. The host -object itself is pure JSI and is shared with the NativeScript Hermes runtime. +installs lazy NativeScript-style globals for native classes, functions, +constants and enums. The host object itself is pure JSI and is shared with the +NativeScript runtime. + +## Requirements + +- React Native 0.79 or newer. +- iOS 13+, with Hermes and the New Architecture. +- `react-native-worklets` 0.8 or newer. It is required on iOS: the pod links + `RNWorklets`, and UIKit work runs on the Worklets UI runtime. +- Android works with Hermes or JSC. See [Android](#android). + +Expo Go cannot load this package because it contains custom native code. Use an +Expo development build, EAS Build, or `npx expo run:ios` / `npx expo run:android`. + +## Installation + +### React Native + +1. Install the package and Worklets: + + ```sh + npm install @nativescript/react-native react-native-worklets + ``` + +2. Configure the project: + + ```sh + npx nativescript-rn configure + ``` + + `configure` adds the NativeScript and Worklets Babel plugins when missing, + writes `nativescript.react-native.json`, applies the Android Gradle script, + adds the Android typings to `tsconfig.json`, and warns when iOS is not + configured for Hermes and the New Architecture. It is intentionally + conservative and does not make destructive native project edits. + + To configure Babel by hand instead: + + ```js + module.exports = { + presets: ["module:@react-native/babel-preset"], + plugins: [ + "@nativescript/react-native/babel-plugin", + ["react-native-worklets/plugin", { strictGlobal: true }], + ], + }; + ``` + + `strictGlobal: true` is required. Without it the Worklets plugin captures + every name a worklet does not declare (`UIView`, `UILabel`, ...) into the + worklet's closure, read on the JavaScript thread when the module defining + the worklet is evaluated. NativeScript's classes do not exist there, so the + app fails at startup with `ReferenceError: Property 'UILabel' doesn't exist`. + With `strictGlobal`, those names are resolved on the runtime the worklet runs + on. With `babel-preset-expo`, also pass `{ worklets: false }` to the preset + so it does not add a second copy of the plugin without `strictGlobal`. + +3. Install the pods: + + ```sh + cd ios + RCT_NEW_ARCH_ENABLED=1 USE_HERMES=1 pod install + ``` + +`npx nativescript-rn generate-metadata --check` prints the iOS metadata +configuration (extra pods and system frameworks) that native builds will use. + +### Expo + +1. Install the package and Worklets: + + ```sh + npx expo install @nativescript/react-native react-native-worklets + ``` + +2. Add the config plugin to `app.json` or `app.config.js`: + + ```json + { + "expo": { + "plugins": ["@nativescript/react-native"] + } + } + ``` + + The plugin: + + - configures iOS for Hermes and the React Native New Architecture, which this + JSI TurboModule requires; + - applies the NativeScript Gradle script to `android/app/build.gradle`; + - adds the `@nativescript/react-native/babel-plugin` and + `react-native-worklets/plugin` (with `{ strictGlobal: true }`) transforms to + `babel.config.js`, and passes `{ worklets: false }` to `babel-preset-expo` + so the preset does not add its own copy of the Worklets plugin. An existing + `react-native-worklets/plugin` entry that already has options is left + alone, with a warning if it lacks `strictGlobal: true`. + + Set `{ "babelPlugin": false }` in the plugin options if you prefer to add the + NativeScript and Worklets Babel plugins manually. + +3. Prebuild and run a development build: + + ```sh + npx expo prebuild + npx expo run:ios + npx expo run:android + ``` + +The plugin also writes `nativescript.react-native.json` so metadata options are +visible to native builds. On iOS you can pass metadata inputs when the app uses +Objective-C-visible pods or extra system frameworks: + +```json +{ + "expo": { + "plugins": [ + [ + "@nativescript/react-native", + { + "metadata": { + "includePods": ["SomeObjCSDK"], + "includeSystemFrameworks": ["UIKit", "MapKit", "WebKit"] + } + } + ] + ] + } +} +``` + +For Android release builds, `metadata.android.include` lists classes to keep; +see [Android setup](#setup). Bare projects using `nativescript-rn configure` can +put the same `metadata` object under a `nativeScriptReactNative` key in +`package.json`. An `android` block written into `nativescript.react-native.json` +by hand is kept unless the options set one. + +Importing the package in a build without its native module is safe: +`NativeScript.isAvailable()` returns `false` there, and `init()` and the other +functions that need the module throw an error saying what to rebuild. + +## Usage + +Call `init()` once, before using native APIs: ```ts import NativeScript from "@nativescript/react-native"; NativeScript.init(); - -const object = NSObject.new(); ``` -`NativeScript.init()` also installs the Native API into the -`react-native-worklets` UI runtime. `NativeScript.runOnUI()` only accepts -Worklets callbacks; running React Native's JS-thread runtime as a UI-thread shim -is not supported. +### Which runtime gets the globals (iOS) + +UIKit must only be touched on the main thread, and React Native's JavaScript +runs on its own JS thread. So on iOS, `init()` installs the full set of native +globals (`UIView`, `NSObject`, `NSTextAlignment`, ...) into the +`react-native-worklets` UI runtime, which runs on the main thread. On the +JavaScript thread it installs only `globalThis.__nativeScriptNativeApi`, +`interop`, and protocol globals. + +Run UIKit work through `NativeScript.runOnUI()` with a Worklets callback: ```ts await NativeScript.runOnUI(() => { @@ -28,26 +174,35 @@ await NativeScript.runOnUI(() => { }); ``` -Install `react-native-worklets`, add its Babel plugin, and run `pod install` so -the `RNWorklets` pod is linked: +On iOS, `runOnUI()` only accepts Worklets callbacks and throws when the callback was not +transformed into a Worklets function (Android runs it on the main thread instead; see +[Threads](#threads)); running React Native's JS-thread runtime +as a UI-thread shim is not supported. `installWorklets()` is still exported for +custom initialization, but it throws when Worklets is unavailable or +incompatible. -```sh -npm install react-native-worklets -``` +For non-UI APIs on the JavaScript thread (Foundation, file system, networking), +either look classes up explicitly or opt into JS-thread globals: -```js -module.exports = { - presets: ["module:@react-native/babel-preset"], - plugins: [ - "@nativescript/react-native/babel-plugin", - "react-native-worklets/plugin", - ], -}; +```ts +const NSDateClass = NativeScript.getClass("NSDate"); + +// or +NativeScript.init("", { globals: true }); +const now = NSDate.new(); ``` -`installWorklets()` is still exported for custom initialization, but it throws -when Worklets is unavailable or incompatible. `runOnUI()` throws when the -callback was not transformed into a Worklets function. +Only full C constant names become globals, for example `NSTextAlignmentCenter` +and `NSOrderedAscending`; short enum member names stay on their enum +(`NSTextAlignment.Center`). JS-thread globals make it possible to touch UIKit +off the main thread by accident, which is why they are opt-in. Helpers that +build native classes, such as `createDelegate()`, need them when called on the +JavaScript thread. + +On Android, `init()` installs the Java package namespaces (`java`, `android`, +...) on the JavaScript runtime. See [Android](#android). + +### Callbacks and threads Obj-C blocks and JS-backed Obj-C method callbacks, including `NSObject.extend` subclass overrides and delegates created with `createDelegate()`, should return @@ -55,13 +210,17 @@ to React Native's JS thread for JS work. Use `jsInvoker()` when a callback can b reached from a native caller thread: ```ts -UIView.animateWithDurationAnimationsCompletion( - 0.25, - null, - NativeScript.jsInvoker((finished) => { - console.log("animation finished", finished); - }), -); +NativeScript.init("", { globals: true }); + +const url = NSURL.URLWithString("https://nativescript.org"); +NSURLSession.sharedSession + .dataTaskWithURLCompletionHandler( + url, + NativeScript.jsInvoker((data, response, error) => { + console.log("request finished", error == null); + }), + ) + .resume(); ``` Delegate, data-source, target/action, and `UIAction` callbacks are JS-side @@ -69,7 +228,7 @@ callbacks. Treat their bodies as JS work. If a callback can be reached from a background native thread and needs to mutate UIKit, wrap the mutation in `NativeScript.runOnUI()` with a Worklets callback. -The package also includes a Babel plugin for directive-style JS callbacks: +The bundled Babel plugin also supports directive-style JS callbacks: ```ts someNativeApi(() => { @@ -145,11 +304,11 @@ badgeRef.current?.invalidateNativeLayout(); React Native view props such as `style`, `testID`, accessibility props, responder props, and `pointerEvents` go to the host component. Your own props go to the -UIKit definition; use `nativeProps(props)` when a plugin prop should also affect -the RN host. The `name` option is forwarded to the shared native host view as a -debug name, so native view descriptions can show `NativeScriptUIView` with your -definition name. It does not dynamically change the registered RN host component -tag. +UIKit definition; add a `nativeProps(props)` function to the definition when a +plugin prop should also affect the RN host. The `name` option is forwarded to the +shared native host view as a debug name, so native view descriptions can show +`NativeScriptUIView` with your definition name. It does not dynamically change +the registered RN host component tag. ### Lifecycle and context @@ -239,7 +398,9 @@ retainer.dispose(); `createDelegate(protocols, methods, options)` accepts protocol objects or names. If metadata was generated before a framework was loaded, use strings with -`NativeScript.loadFramework()` and `NativeScript.getProtocol()`: +`NativeScript.loadFramework()` and `NativeScript.getProtocol()`. Passing +`{ owner: ctx }` from a definition's `create(ctx)` ties the delegate to the +component's lifetime: ```ts NativeScript.loadFramework("QuickLook"); @@ -251,7 +412,7 @@ const dataSource = NativeScript.createDelegate( return 1; }, previewControllerPreviewItemAtIndex() { - return NSURL.fileURLWithPath(path); + return NSURL.fileURLWithPath(ctx.props.path); }, }, { owner: ctx }, @@ -397,12 +558,13 @@ export const NativeTabs = NativeScript.defineUIViewController< ``` For modal UIKit controllers, find the top visible presenter and guard against -double presentation: +double presentation. Helpers called from a worklet must be worklets too: ```ts function topVisibleViewController( root = UIApplication.sharedApplication.keyWindow?.rootViewController, ) { + "worklet"; let current = root; while (current?.presentedViewController) { current = current.presentedViewController; @@ -440,7 +602,6 @@ if ( const CameraController = NativeScript.getClass< typeof VNDocumentCameraViewController >("VNDocumentCameraViewController"); - const controller = CameraController?.new(); } ``` @@ -461,168 +622,11 @@ to JS errors where Objective-C can catch them. Process-level failures such as violations are not catchable; use availability checks and presentation guards instead of relying on exceptions as control flow. -The package ships example definitions under `@nativescript/react-native/examples`. - -The published package includes generated NativeScript metadata, the libffi -xcframework, and generated iOS SDK TypeScript declarations. Build it from the -repository root with: - -```sh -npm run build-rn-turbomodule -``` - -The tarball is written to `packages/react-native/dist/` and copied to -`build/npm-tarballs/`. - -To verify it inside a generated React Native iOS app: - -```sh -npm run test-rn-turbomodule -``` - -## Using the package in a React Native app - -1. Build or download the package tarball. -2. Install it in an RN app that has Hermes and the New Architecture enabled: - - ```sh - npm install /path/to/nativescript-react-native-0.0.1.tgz react-native-worklets - cd ios - RCT_NEW_ARCH_ENABLED=1 USE_HERMES=1 pod install - ``` - -3. Initialize it before using native APIs: - - ```ts - import NativeScript from "@nativescript/react-native"; - - NativeScript.init(); - - await NativeScript.runOnUI(() => { - "worklet"; - UIApplication.sharedApplication.keyWindow.tintColor = - UIColor.systemPinkColor; - }); - ``` - -4. Add the bundled NativeScript Babel plugin and the Worklets Babel plugin: - - ```js - module.exports = { - presets: ["module:@react-native/babel-preset"], - plugins: [ - "@nativescript/react-native/babel-plugin", - "react-native-worklets/plugin", - ], - }; - ``` - -## Using the package in an Expo app - -Expo Go cannot load this package because it contains custom native code. Use an -Expo development build, EAS Build, or `npx expo run:ios`. +More example definitions ship in the package's `examples/` folder, for example +`@nativescript/react-native/examples/UIKitSwitch`. -1. Install the package: - - ```sh - npx expo install @nativescript/react-native react-native-worklets - ``` - - When testing a local tarball: - - ```sh - npm install /path/to/nativescript-react-native-0.0.1.tgz - ``` - -2. Add the config plugin to `app.json` or `app.config.js`: - - ```json - { - "expo": { - "plugins": ["@nativescript/react-native"] - } - } - ``` - - The plugin configures iOS for Hermes and the React Native New Architecture, - which are required by this JSI TurboModule. It also adds the - `@nativescript/react-native/babel-plugin` and `react-native-worklets/plugin` - transforms to `babel.config.js` so `"use js"` and worklet callbacks work in - Expo bundles. - -3. Prebuild and run the iOS development build: - - ```sh - npx expo prebuild --platform ios - npx expo run:ios - ``` - -4. Initialize NativeScript in app code before using native APIs: - - ```tsx - import NativeScript, { defineUIKitView } from "@nativescript/react-native"; - - NativeScript.init(); - - const NativeBadge = defineUIKitView<{ title: string }, UIView>({ - name: "NativeBadge", - create() { - const view = UIView.alloc().initWithFrame(CGRectZero); - const label = UILabel.alloc().initWithFrame(CGRectZero); - label.tag = 1; - label.textAlignment = NSTextAlignment.Center; - view.addSubview(label); - return view; - }, - update(view, props) { - view.backgroundColor = UIColor.systemBlueColor; - const label = view.viewWithTag(1) as UILabel; - label.text = props.title; - }, - }); - ``` - -Set `{ "babelPlugin": false }` in the config plugin options if you prefer to add -the NativeScript and Worklets Babel plugins manually. - -The plugin also writes `nativescript.react-native.json` so metadata options are -visible to native builds. You can pass metadata inputs when the app uses -Objective-C-visible pods or extra system frameworks: - -```json -{ - "expo": { - "plugins": [ - [ - "@nativescript/react-native", - { - "metadata": { - "includePods": ["SomeObjCSDK"], - "includeSystemFrameworks": ["UIKit", "MapKit", "WebKit"] - } - } - ] - ] - } -} -``` - -## Bare React Native setup helper - -The tarball includes a small CLI for bare RN projects: - -```sh -npx nativescript-rn configure -npx nativescript-rn generate-metadata --check -cd ios -RCT_NEW_ARCH_ENABLED=1 USE_HERMES=1 pod install -``` - -`configure` adds the bundled NativeScript and Worklets Babel plugins when -missing, writes -`nativescript.react-native.json`, and warns when the app is not configured for -Hermes and the New Architecture. The command is intentionally conservative and -does not make destructive native project edits. +The package includes generated NativeScript metadata, the libffi xcframework, +and generated iOS SDK TypeScript declarations. ## Android @@ -631,9 +635,9 @@ Android gives you Java and the Android SDK — plus every library your app depends on, because the metadata is generated from your app's own classpath. ```ts -import NativeScriptNativeApi from "@nativescript/react-native/src/NativeScriptNativeApi"; +import NativeScript from "@nativescript/react-native"; -NativeScriptNativeApi.install(""); +NativeScript.init(); const now = java.lang.System.currentTimeMillis(); const sdk = android.os.Build.VERSION.SDK_INT; @@ -650,8 +654,8 @@ prebuilt binary could not be safe across them. ### Setup -`npx nativescript-rn configure` adds the Gradle script and the typings entries. -To do it by hand, in `android/app/build.gradle`, after the React plugin: +`npx nativescript-rn configure` and the Expo config plugin both add the Gradle +script. To do it by hand, in `android/app/build.gradle`, after the React plugin: ```groovy apply from: "../../node_modules/@nativescript/react-native/android/nativescript.gradle" @@ -659,19 +663,57 @@ apply from: "../../node_modules/@nativescript/react-native/android/nativescript. That adds two things to your build: -* **Metadata** — a description of every class on the app's resolved classpath, +* **Metadata** — a description of the classes on the app's resolved classpath, generated into the APK's assets on every build. This is what makes `android.widget.Toast` and any third-party library resolvable from JavaScript. `whitelist.mdg` / `blacklist.mdg` next to `android/app/build.gradle` filter it, with the same format the NativeScript CLI uses. -* **TypeScript declarations** — off by default because they take a while: + + Debug builds ship the whole classpath. Release builds ship only what the + JavaScript bundle can reach, plus the supertypes, nested classes and + signature types those classes need. They also leave out the packages of + autolinked React Native modules, which are reached over the TurboModule + bridge rather than the interop. A class your code only reaches dynamically + (a name built at runtime, for example) can therefore resolve in debug and be + missing in release. Declare such classes in `nativescript.react-native.json` + at the project root, as `"pkg.Class"`, `"pkg.*"` or `"pkg:Class"`: + + ```json + { + "reactNative": { + "metadata": { + "android": { + "include": ["com.example.sdk.Worker", "com.example.views.*"] + } + } + } + } + ``` + + With Expo, put the same `android` object under `metadata` in the config + plugin options. `-PnsFilterMetadata=false` turns the filter off and ships + the whole classpath. +* **TypeScript declarations** — for the same classpath, so third-party + libraries are typed too. They are written to `types/android/` next to the app + on the first build and regenerated only when the classpath changes; commit + them alongside your code. `-PnsGenerateTypings=false` opts out, and ```bash - cd android && ./gradlew generateNativeScriptTypingsDebug -PnsGenerateTypings=true + cd android && ./gradlew generateNativeScriptTypingsDebug + ``` + + generates them on their own. `configure` adds them to `tsconfig.json`; with + the Expo config plugin, add them yourself: + + ```json + { + "include": ["types/android/android.d.ts", "types/android/android-declarations.d.ts"] + } ``` - which writes `types/android/` and, with `configure` having run, is already - referenced from `tsconfig.json`. + The package does not ship Android declarations, and `@nativescript/types-android` + is not needed: it covers only the Android SDK, and its declarations collide + with the generated ones. ### Extending Java classes @@ -769,11 +811,54 @@ const listener = new android.view.View.OnClickListener({ onClick(v) {} }); ### Threads -React Native owns the JavaScript thread, and JSI may only be touched there. A -Java callback arriving on another thread is marshalled onto the JS thread for -you. The one thing to avoid is blocking the JS thread while waiting for such a -callback — `thread.join()` immediately after `thread.start()` deadlocks, because -the callback cannot run until the JS thread is free again. +React Native runs JavaScript on its own thread, and Android views belong to the +main thread. JSI may be entered from one thread at a time, so when JavaScript +has to run on the main thread, the JS thread stops and waits while it does. +React Native hands its runtime over the same way for its own synchronous work. + +- **Callbacks the main thread makes** (a view's listeners, a `Runnable` posted + to `new Handler(Looper.getMainLooper())`) run their JavaScript on the main + thread, so they can touch views. +- **Callbacks from any other thread** are marshalled onto the JS thread. Promises + they resolve settle when the callback returns. +- **`runOnUI(callback, ...args)`** runs `callback` on the main thread and + resolves with what it returns. There is no Worklets runtime involved: it is the + same runtime, so `callback` sees the caller's values, not copies. The Worklets + Babel plugin still turns a function passed to `runOnUI` into a worklet, which + drops its `this`; pass what it needs as arguments. + +Don't block the JS thread while it waits for one of these. `thread.join()` +straight after `thread.start()` deadlocks, because the callback can't run until +the JS thread is free. + +### Android views + +`runOnUI` builds a view on the main thread. `androidContext()` returns the +Context to build it with, and `nativeViewHandle(view)` returns the string a +`NativeScriptUIView` takes to show it. The host sizes the view to fill its own +layout box: + +```tsx +import NativeScript, { androidContext, nativeViewHandle } from "@nativescript/react-native"; +import NativeScriptUIView from "@nativescript/react-native/src/NativeScriptUIViewNativeComponent"; + +const views = new Map(); + +async function makeLabel(id: string, text: string) { + return NativeScript.runOnUI((id: string, text: string) => { + const label = new android.widget.TextView(androidContext()); + label.setText(text); + views.set(id, label); // A handle does not keep its view alive. + return nativeViewHandle(label); + }, id, text); +} + +// +``` + +A view is in one place at a time: showing its handle in a second host moves it +there. Update a view from `runOnUI` or from one of its own listeners. The +`defineUIKitView` helpers are UIKit's and remain iOS only. ### What a guest does not install @@ -782,3 +867,19 @@ React Native already provides `console`, timers, `queueMicrotask`, installs only the metadata globals, the object manager and the interop callbacks. `Worker` is not installed either: a worker needs its own VM, which a guest has no way to create. + +## Building from source + +From the root of the [NativeScript/runtimes](https://github.com/NativeScript/runtimes) +repository: + +```sh +npm run build-rn-turbomodule +``` + +The tarball is written to `packages/react-native/dist/` and copied to +`build/npm-tarballs/`. To verify it inside a generated React Native iOS app: + +```sh +npm run test-rn-turbomodule +``` diff --git a/packages/react-native/android/src/main/cpp/NativeScriptRnInstall.cpp b/packages/react-native/android/src/main/cpp/NativeScriptRnInstall.cpp index fcf76578f..197935632 100644 --- a/packages/react-native/android/src/main/cpp/NativeScriptRnInstall.cpp +++ b/packages/react-native/android/src/main/cpp/NativeScriptRnInstall.cpp @@ -7,9 +7,12 @@ // runtime compiles; all that differs is who created the runtime it installs // into. See NativeScript/runtime/android/jsi/EngineHost.h. +#include #include #include +#include +#include #include #include "ArgConverter.h" @@ -20,10 +23,150 @@ using namespace tns; namespace { -// Set once the first attach succeeds. React Native can reload the JS bundle, -// which builds a fresh jsi::Runtime; the module checks this before installing -// so a reload rebinds rather than stacking a second runtime on the first. -bool g_attached = false; +jmethodID g_runOnMainThread = nullptr; +jclass g_viewRegistry = nullptr; +jmethodID g_viewHandleFor = nullptr; +jmethodID g_viewContext = nullptr; + +bool IsMainThread() { return gettid() == getpid(); } + +// Handed to the main thread by address, through com.tns.Runtime.runOnMainThread, +// while the JS thread that owns it waits. +struct MainThreadTask { + explicit MainThreadTask(engine::Value function) : function(std::move(function)) {} + + engine::Value function; + engine::Value result; + std::exception_ptr error; + bool ran = false; +}; + +Runtime* FindRuntime(int runtimeId) { + try { + return Runtime::GetRuntime(runtimeId); + } catch (NativeScriptException&) { + return nullptr; + } +} + +engine::Value RunOnMainThread(int runtimeId, engine::Runtime& rt, const engine::Value* args, + size_t count) { + if (count < 1 || !args[0].isObject() || !args[0].asObjectBorrowed(rt).isFunction(rt)) { + throw engine::JSError(rt, "__nativeScriptRunOnMainThread expects a function"); + } + if (IsMainThread()) { + return args[0].asObject(rt).asFunction(rt).call(rt); + } + + Runtime* runtime = FindRuntime(runtimeId); + if (runtime == nullptr || runtime->is_destroying) { + throw engine::JSError(rt, "NativeScript is no longer attached to this JavaScript runtime"); + } + + MainThreadTask task(engine::Value(rt, args[0])); + JEnv env; + env.CallVoidMethod(runtime->GetJavaRuntime(), g_runOnMainThread, + static_cast(reinterpret_cast(&task))); + if (env.ExceptionCheck()) { + NativeScriptException(env).ReThrowToJs(rt); + } + if (task.error) { + std::rethrow_exception(task.error); + } + if (!task.ran) { + throw engine::JSError(rt, "The main thread did not run the function: the app is shutting down"); + } + return std::move(task.result); +} + +engine::Value ViewHandle(int runtimeId, engine::Runtime& rt, const engine::Value* args, + size_t count) { + Runtime* runtime = FindRuntime(runtimeId); + JniLocalRef view; + if (runtime != nullptr && count > 0 && args[0].isObject()) { + view = runtime->GetObjectManager()->GetJavaObjectByJsObject(args[0]); + } + if (view.IsNull()) { + throw engine::JSError(rt, "nativeViewHandle expects an android.view.View"); + } + + JEnv env; + JniLocalRef handle(env.CallStaticObjectMethod(g_viewRegistry, g_viewHandleFor, (jobject) view)); + if (env.ExceptionCheck()) { + NativeScriptException(env).ReThrowToJs(rt); + } + if (handle.IsNull()) { + throw engine::JSError(rt, "nativeViewHandle expects an android.view.View"); + } + return ArgConverter::jstringToJsString(rt, (jstring) (jobject) handle); +} + +engine::Value ViewContext(int runtimeId, engine::Runtime& rt) { + Runtime* runtime = FindRuntime(runtimeId); + if (runtime == nullptr) { + return engine::Value::null(); + } + JEnv env; + JniLocalRef context(env.CallStaticObjectMethod(g_viewRegistry, g_viewContext)); + if (env.ExceptionCheck()) { + NativeScriptException(env).ReThrowToJs(rt); + } + if (context.IsNull()) { + return engine::Value::null(); + } + ObjectManager* objectManager = runtime->GetObjectManager(); + jint id = objectManager->GetOrCreateObjectId(context); + engine::Value value = objectManager->GetJsObjectByJavaObject(id); + if (js_util::is_null_or_undefined(value)) { + value = objectManager->CreateJSWrapper(id, ObjectManager::GetClassName((jobject) context)); + } + return value; +} + +// What makes Android views reachable: run on the main thread, a Context to build +// a View with, and a name for a NativeScriptUIView to show it by. src/index.ts +// wraps them. +void InstallGuestFunctions(JNIEnv* env, jobject javaRuntime, Runtime* runtime) { + if (g_runOnMainThread == nullptr) { + g_runOnMainThread = + env->GetMethodID(env->GetObjectClass(javaRuntime), "runOnMainThread", "(J)V"); + // The class has to be found here, on a thread entered from Java: FindClass + // on any other thread searches only the system class loader. + jclass registry = env->FindClass("org/nativescript/reactnative/NativeScriptViewRegistry"); + g_viewRegistry = static_cast(env->NewGlobalRef(registry)); + g_viewHandleFor = env->GetStaticMethodID(g_viewRegistry, "handleFor", + "(Ljava/lang/Object;)Ljava/lang/String;"); + g_viewContext = env->GetStaticMethodID(g_viewRegistry, "viewContext", + "()Landroid/content/Context;"); + env->DeleteLocalRef(registry); + } + + JSScope scope(runtime->GetEngineHost()); + engine::Runtime& rt = runtime->GetEngineHost()->GetRuntime(); + engine::Object global = rt.global(); + const int runtimeId = runtime->GetId(); + + engine_util::SetFunction( + rt, global, "__nativeScriptIsMainThread", + [](engine::Runtime&, const engine::Value&, const engine::Value*, size_t) { + return engine::Value(IsMainThread()); + }); + engine_util::SetFunction( + rt, global, "__nativeScriptRunOnMainThread", + [runtimeId](engine::Runtime& rt, const engine::Value&, const engine::Value* args, + size_t count) { return RunOnMainThread(runtimeId, rt, args, count); }, + 1); + engine_util::SetFunction( + rt, global, "__nativeScriptAndroidContext", + [runtimeId](engine::Runtime& rt, const engine::Value&, const engine::Value*, size_t) { + return ViewContext(runtimeId, rt); + }); + engine_util::SetFunction( + rt, global, "__nativeScriptAndroidViewHandle", + [runtimeId](engine::Runtime& rt, const engine::Value&, const engine::Value* args, + size_t count) { return ViewHandle(runtimeId, rt, args, count); }, + 1); +} } // namespace @@ -59,7 +202,7 @@ extern "C" JNIEXPORT void JNICALL Java_com_tns_Runtime_attachNativeScript( } } - g_attached = true; + InstallGuestFunctions(env, obj, runtime); } catch (NativeScriptException& e) { e.ReThrowToJava(nullptr); } catch (std::exception& e) { @@ -71,9 +214,33 @@ extern "C" JNIEXPORT void JNICALL Java_com_tns_Runtime_attachNativeScript( } } +extern "C" JNIEXPORT void JNICALL Java_com_tns_Runtime_detachNativeScript(JNIEnv*, jobject, + jint runtimeId) { + try { + Runtime::GetRuntime(runtimeId)->Detach(); + } catch (NativeScriptException& e) { + e.ReThrowToJava(nullptr); + } catch (std::exception& e) { + NativeScriptException nsEx(std::string("Error: c++ exception: ") + e.what()); + nsEx.ReThrowToJava(nullptr); + } catch (...) { + NativeScriptException nsEx(std::string("Error: c++ exception!")); + nsEx.ReThrowToJava(nullptr); + } +} + +// Per host runtime: a reload replaces the jsi::Runtime, and the new one has +// none of NativeScript's globals until it is attached in turn. extern "C" JNIEXPORT jboolean JNICALL -Java_org_nativescript_reactnative_NativeScriptNativeApiModule_nativeIsInstalled(JNIEnv*, jobject) { - return g_attached ? JNI_TRUE : JNI_FALSE; +Java_org_nativescript_reactnative_NativeScriptNativeApiModule_nativeIsInstalled( + JNIEnv*, jobject, jlong hostRuntimePtr) { + if (hostRuntimePtr == 0) { + return JNI_FALSE; + } + // Never dereferenced: the wrapper only carries the pointer as its identity. + engine::Runtime probe(*reinterpret_cast(hostRuntimePtr)); + Runtime* runtime = Runtime::GetRuntimeUnchecked(probe); + return (runtime != nullptr && !runtime->is_destroying) ? JNI_TRUE : JNI_FALSE; } extern "C" JNIEXPORT jstring JNICALL @@ -83,3 +250,65 @@ Java_org_nativescript_reactnative_NativeScriptNativeApiModule_nativeRuntimeBacke // talks jsi and cannot tell Hermes from JSC, and does not need to. return env->NewStringUTF("jsi"); } + +// The guest half of com.tns.MainThreadLoan. + +extern "C" JNIEXPORT void JNICALL Java_com_tns_Runtime_runMainThreadTaskNative(JNIEnv*, jobject, + jint runtimeId, + jlong taskPtr) { + auto* task = reinterpret_cast(static_cast(taskPtr)); + try { + Runtime* runtime = Runtime::GetRuntime(runtimeId); + JSScope scope(runtime->GetEngineHost()); + engine::Runtime& rt = runtime->GetEngineHost()->GetRuntime(); + task->result = task->function.asObject(rt).asFunction(rt).call(rt); + } catch (...) { + task->error = std::current_exception(); + } + task->ran = true; +} + +extern "C" JNIEXPORT jint JNICALL Java_com_tns_Runtime_releaseEngineLockNative(JNIEnv*, jobject, + jint runtimeId) { + Runtime* runtime = FindRuntime(runtimeId); + return runtime != nullptr ? runtime->GetEngineHost()->ReleaseAll() : 0; +} + +extern "C" JNIEXPORT void JNICALL Java_com_tns_Runtime_reacquireEngineLockNative(JNIEnv*, jobject, + jint runtimeId, + jint depth) { + Runtime* runtime = FindRuntime(runtimeId); + if (runtime != nullptr) { + runtime->GetEngineHost()->Reacquire(depth); + } +} + +extern "C" JNIEXPORT void JNICALL Java_com_tns_Runtime_drainMicrotasksNative(JNIEnv*, jobject, + jint runtimeId) { + Runtime* runtime = FindRuntime(runtimeId); + if (runtime == nullptr || runtime->is_destroying) { + return; + } + JSScope scope(runtime->GetEngineHost()); + facebook::jsi::Runtime& rt = runtime->GetEngineHost()->GetRuntime().jsi(); + try { + rt.drainMicrotasks(); + } catch (facebook::jsi::JSError& e) { + // Where React Native sends an error thrown by one of its own tasks. + try { + auto errorUtils = rt.global().getProperty(rt, "ErrorUtils"); + if (errorUtils.isObject()) { + errorUtils.asObject(rt) + .getPropertyAsFunction(rt, "reportFatalError") + .call(rt, e.value()); + return; + } + } catch (std::exception&) { + } + __android_log_print(ANDROID_LOG_ERROR, "TNS.Native", "Uncaught error in a microtask: %s", + e.what()); + } catch (std::exception& e) { + __android_log_print(ANDROID_LOG_ERROR, "TNS.Native", "Uncaught error in a microtask: %s", + e.what()); + } +} diff --git a/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptNativeApiModule.java b/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptNativeApiModule.java index d07d099b8..01a114b27 100644 --- a/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptNativeApiModule.java +++ b/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptNativeApiModule.java @@ -3,6 +3,7 @@ import android.content.Context; import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.queue.MessageQueueThread; import com.facebook.react.module.annotations.ReactModule; import com.tns.Logger; @@ -23,6 +24,11 @@ * thread -- it is a synchronous TurboModule method, so React Native already * guarantees that. Everything it does happens against the runtime React Native * created; nothing here starts a JavaScript engine. + * + *

React Native creates one instance of this module per JavaScript runtime. A + * reload destroys the runtime and invalidates the module first, which is where + * NativeScript lets go of it; the reloaded bundle then installs into its new + * runtime through a new instance. */ @ReactModule(name = NativeScriptNativeApiModule.NAME) public class NativeScriptNativeApiModule extends NativeScriptNativeApiSpec { @@ -44,9 +50,12 @@ public class NativeScriptNativeApiModule extends NativeScriptNativeApiSpec { private final ReactApplicationContext reactContext; + private Runtime attachedRuntime; + public NativeScriptNativeApiModule(ReactApplicationContext reactContext) { super(reactContext); this.reactContext = reactContext; + NativeScriptViewRegistry.setReactContext(reactContext); } @Override @@ -54,13 +63,19 @@ public String getName() { return NAME; } - private native boolean nativeIsInstalled(); + private native boolean nativeIsInstalled(long hostRuntimePtr); private native String nativeRuntimeBackend(); @Override public boolean install(String metadataPath) { - if (nativeIsInstalled()) { + long runtimePtr = hostRuntimePtr(); + if (runtimePtr == 0) { + throw new RuntimeException( + "React Native did not expose a JavaScript runtime. NativeScript requires the " + + "JSI runtime and cannot run over the legacy bridge."); + } + if (nativeIsInstalled(runtimePtr)) { return true; } @@ -78,33 +93,56 @@ public boolean install(String metadataPath) { + "into the APK.", e); } - // The runtime reads a jsi::Runtime pointer, not a bridge. This holder is - // how React Native hands one out, and it stays valid for the life of the - // JS context -- a reload replaces the context and re-runs install(). - long runtimePtr = reactContext.getJavaScriptContextHolder().get(); - if (runtimePtr == 0) { - throw new RuntimeException( - "React Native did not expose a JavaScript runtime. NativeScript requires the " - + "JSI runtime and cannot run over the legacy bridge."); - } - - attachRuntime(context, runtimePtr, resolvedPath, readAsset(context, ASSET_TS_HELPERS)); + attachedRuntime = attachRuntime(context, runtimePtr, resolvedPath, + readAsset(context, ASSET_TS_HELPERS)); return true; } + @Override + public void invalidate() { + Runtime runtime = attachedRuntime; + attachedRuntime = null; + if (runtime != null) { + // Detaching releases handles into the jsi::Runtime: it has to run + // before React Native destroys that runtime, and never alongside + // JS. Bridgeless has already stopped the JS thread here, so the post + // is refused and this thread is safe. The bridge can invalidate + // from another thread while JS still runs, and queues the runtime's + // destruction on the JS thread behind this. + MessageQueueThread jsThread = reactContext.getJSMessageQueueThread(); + if (jsThread == null || jsThread.isOnThread() || !jsThread.runOnQueue(runtime::detach)) { + runtime.detach(); + } + } + super.invalidate(); + } + @Override public boolean installWorkletRuntime(com.facebook.react.bridge.ReadableMap runtimeHolder, String metadataPath) { // Worklet runtimes are separate jsi::Runtimes on separate threads. Each // would need its own attach and its own object manager, which the object // graph does not yet model on Android; iOS reaches this through - // NativeApiJsi.mm. Reported honestly rather than silently no-oped. + // NativeApiJsi.mm. Android's runOnUI lends this runtime to the main + // thread instead (com.tns.MainThreadLoan), so it needs no second one. return false; } @Override public boolean isInstalled() { - return nativeIsInstalled(); + return nativeIsInstalled(hostRuntimePtr()); + } + + /** + * The jsi::Runtime this module's JavaScript runs in, or 0. + * + *

Fixed for the life of this module instance. React Native exposes no + * other handle to it, and the runtime reads a pointer rather than a bridge. + */ + private long hostRuntimePtr() { + com.facebook.react.bridge.JavaScriptContextHolder holder = + reactContext.getJavaScriptContextHolder(); + return holder != null ? holder.get() : 0; } /** @@ -138,8 +176,8 @@ public boolean __writeTestMarker(String content) { } } - private static void attachRuntime(Context context, long runtimePtr, String metadataPath, - String bootstrapScript) { + private static Runtime attachRuntime(Context context, long runtimePtr, String metadataPath, + String bootstrapScript) { File filesDir = context.getFilesDir(); File dexDir = new File(filesDir, "nativescript-dex"); if (!dexDir.exists() && !dexDir.mkdirs()) { @@ -148,7 +186,7 @@ private static void attachRuntime(Context context, long runtimePtr, String metad Logger logger = new NativeScriptLogger(isDebuggable(context)); - Runtime.attachToHostRuntime( + return Runtime.attachToHostRuntime( logger, context.getPackageName(), context.getApplicationInfo().nativeLibraryDir, diff --git a/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptNativeApiPackage.java b/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptNativeApiPackage.java index 443c1b570..f6b8b4fe9 100644 --- a/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptNativeApiPackage.java +++ b/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptNativeApiPackage.java @@ -7,8 +7,11 @@ import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.module.model.ReactModuleInfo; import com.facebook.react.module.model.ReactModuleInfoProvider; +import com.facebook.react.uimanager.ViewManager; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; /** Autolinking entry point for @nativescript/react-native. */ @@ -23,6 +26,12 @@ public NativeModule getModule(String name, ReactApplicationContext reactContext) return null; } + @SuppressWarnings("rawtypes") + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.singletonList(new NativeScriptUIViewManager()); + } + @Override public ReactModuleInfoProvider getReactModuleInfoProvider() { return () -> { diff --git a/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptUIHostView.java b/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptUIHostView.java new file mode 100644 index 000000000..7e20b23a9 --- /dev/null +++ b/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptUIHostView.java @@ -0,0 +1,75 @@ +package org.nativescript.reactnative; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; + +import androidx.annotation.Nullable; + +/** Shows the view a NativeScriptUIView's handle names, filling the host. */ +final class NativeScriptUIHostView extends ViewGroup { + @Nullable + private View hosted; + private boolean layoutPending; + + private final Runnable measureAndLayout = () -> { + layoutPending = false; + measure(MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(getHeight(), MeasureSpec.EXACTLY)); + layout(getLeft(), getTop(), getRight(), getBottom()); + }; + + NativeScriptUIHostView(Context context) { + super(context); + } + + void setNativeViewHandle(@Nullable String handle) { + View next = NativeScriptViewRegistry.resolve(handle); + if (next == hosted && (next == null || next.getParent() == this)) { + return; + } + if (hosted != null && hosted.getParent() == this) { + removeView(hosted); + } + hosted = next; + if (next != null) { + // A view is in one place at a time; a handle shown by a new host + // moves there, as a UIView does on iOS. + ViewParent parent = next.getParent(); + if (parent instanceof ViewGroup) { + ((ViewGroup) parent).removeView(next); + } + addView(next, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), + MeasureSpec.getSize(heightMeasureSpec)); + if (hosted != null && hosted.getParent() == this) { + hosted.measure(MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.EXACTLY)); + } + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + if (hosted != null && hosted.getParent() == this) { + hosted.layout(0, 0, right - left, bottom - top); + } + } + + @Override + public void requestLayout() { + super.requestLayout(); + // React Native positions its own views and ends a layout request at the + // first of them, so one from the hosted view (new text, a new child) + // would otherwise never be served. Null while ViewGroup's constructor runs. + if (measureAndLayout != null && !layoutPending) { + layoutPending = true; + post(measureAndLayout); + } + } +} diff --git a/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptUIViewManager.java b/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptUIViewManager.java new file mode 100644 index 000000000..767cc5010 --- /dev/null +++ b/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptUIViewManager.java @@ -0,0 +1,87 @@ +package org.nativescript.reactnative; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.facebook.react.module.annotations.ReactModule; +import com.facebook.react.uimanager.SimpleViewManager; +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.uimanager.ViewManagerDelegate; +import com.facebook.react.viewmanagers.NativeScriptUIViewManagerDelegate; +import com.facebook.react.viewmanagers.NativeScriptUIViewManagerInterface; + +/** + * NativeScriptUIView on Android: shows the android.view.View its + * {@code nativeViewHandle} names (see {@link NativeScriptViewRegistry}). + * + *

The other props drive iOS's UIKit host (controllers, children views, host + * ready events) and have no Android counterpart. + */ +@ReactModule(name = NativeScriptUIViewManager.NAME) +public class NativeScriptUIViewManager extends SimpleViewManager + implements NativeScriptUIViewManagerInterface { + public static final String NAME = "NativeScriptUIView"; + + private final ViewManagerDelegate delegate = + new NativeScriptUIViewManagerDelegate<>(this); + + @NonNull + @Override + public String getName() { + return NAME; + } + + @Override + protected ViewManagerDelegate getDelegate() { + return delegate; + } + + @NonNull + @Override + protected NativeScriptUIHostView createViewInstance(@NonNull ThemedReactContext context) { + return new NativeScriptUIHostView(context); + } + + @Override + public void onDropViewInstance(@NonNull NativeScriptUIHostView view) { + super.onDropViewInstance(view); + view.setNativeViewHandle(null); + } + + @Override + public void setNativeViewHandle(NativeScriptUIHostView view, @Nullable String value) { + view.setNativeViewHandle(value); + } + + @Override + public void setHostId(NativeScriptUIHostView view, @Nullable String value) { + } + + @Override + public void setHostReadyId(NativeScriptUIHostView view, @Nullable String value) { + } + + @Override + public void setChildrenViewHandle(NativeScriptUIHostView view, @Nullable String value) { + } + + @Override + public void setControllerHandle(NativeScriptUIHostView view, @Nullable String value) { + } + + @Override + public void setDetachControllerView(NativeScriptUIHostView view, boolean value) { + } + + @Override + public void setDebugName(NativeScriptUIHostView view, @Nullable String value) { + } + + @Override + public void setUpdateRevision(NativeScriptUIHostView view, int value) { + } + + @Override + public void setMountedRevision(NativeScriptUIHostView view, int value) { + } +} diff --git a/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptViewRegistry.java b/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptViewRegistry.java new file mode 100644 index 000000000..f61800206 --- /dev/null +++ b/packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptViewRegistry.java @@ -0,0 +1,74 @@ +package org.nativescript.reactnative; + +import android.app.Activity; +import android.content.Context; +import android.view.View; + +import com.facebook.react.bridge.ReactContext; + +import java.lang.ref.WeakReference; +import java.util.HashMap; +import java.util.Map; +import java.util.WeakHashMap; + +/** + * Names Android views for {@link NativeScriptUIViewManager}: the string a + * NativeScriptUIView takes as its {@code nativeViewHandle}. + * + *

Holds views weakly, as the pointer iOS uses for a handle does not retain + * its UIView: whoever made the view keeps it alive until a host shows it. + */ +public final class NativeScriptViewRegistry { + private static final Map handles = new WeakHashMap<>(); + private static final Map> views = new HashMap<>(); + private static long nextId; + private static WeakReference reactContext = new WeakReference<>(null); + + private NativeScriptViewRegistry() { + } + + static synchronized void setReactContext(ReactContext context) { + reactContext = new WeakReference<>(context); + } + + /** + * Called from JavaScript through __nativeScriptAndroidContext: the Context + * to build a view with. The current Activity, whose theme the view takes; + * the application before one exists. + */ + public static synchronized Context viewContext() { + ReactContext context = reactContext.get(); + if (context == null) { + return null; + } + Activity activity = context.getCurrentActivity(); + return activity != null ? activity : context.getApplicationContext(); + } + + /** Called from JavaScript through __nativeScriptAndroidViewHandle. Null for a non-View. */ + public static synchronized String handleFor(Object object) { + if (!(object instanceof View)) { + return null; + } + View view = (View) object; + String handle = handles.get(view); + if (handle == null) { + if (views.size() >= 64 && views.size() > 2 * handles.size()) { + views.values().removeIf(ref -> ref.get() == null); + } + handle = "android:" + (++nextId); + handles.put(view, handle); + views.put(handle, new WeakReference<>(view)); + } + return handle; + } + + public static synchronized View resolve(String handle) { + WeakReference ref = handle != null ? views.get(handle) : null; + View view = ref != null ? ref.get() : null; + if (ref != null && view == null) { + views.remove(handle); + } + return view; + } +} diff --git a/packages/react-native/cli/configure.js b/packages/react-native/cli/configure.js index e2e271e10..79c4a015d 100755 --- a/packages/react-native/cli/configure.js +++ b/packages/react-native/cli/configure.js @@ -26,8 +26,9 @@ function printNextSteps() { console.log(' iOS : cd ios && RCT_NEW_ARCH_ENABLED=1 USE_HERMES=1 pod install'); console.log(' Android : npx react-native run-android'); console.log(''); - console.log(' Android typings are generated on demand:'); - console.log(' cd android && ./gradlew generateNativeScriptTypingsDebug -PnsGenerateTypings=true'); + console.log(' Android typings are written to types/android/ on the first build'); + console.log(' (opt out with -PnsGenerateTypings=false). To generate them on their own:'); + console.log(' cd android && ./gradlew generateNativeScriptTypingsDebug'); } function configure(argv = process.argv.slice(2)) { diff --git a/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.mm b/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.mm index f94f048bb..39e07ae9d 100644 --- a/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.mm +++ b/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.mm @@ -10,6 +10,11 @@ using namespace facebook::react; +static const std::shared_ptr& NativeScriptUIViewDefaultProps() { + static const auto defaultProps = std::make_shared(); + return defaultProps; +} + static BOOL NativeScriptFabricViewIsDescendantOfView(UIView* view, UIView* ancestor) { UIView* current = view; while (current != nil) { @@ -78,8 +83,7 @@ @implementation NativeScriptUIViewComponentView { - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { - static const auto defaultProps = std::make_shared(); - _props = defaultProps; + _props = NativeScriptUIViewDefaultProps(); _containerView = [[NativeScriptUIView alloc] initWithFrame:self.bounds]; _containerView.hostReadyDelegate = self; @@ -190,7 +194,12 @@ - (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event { } - (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)oldProps { - const auto oldViewProps = std::static_pointer_cast(_props); + // Diff against oldProps, not _props: React Native leaves _props untouched on + // recycle, while prepareForRecycle clears the container, so a recycled view + // mounted with its previous handle would otherwise never attach it. + const auto oldViewProps = + oldProps ? std::static_pointer_cast(oldProps) + : NativeScriptUIViewDefaultProps(); const auto newViewProps = std::static_pointer_cast(props); const std::string oldNativeViewHandle = oldViewProps->nativeViewHandle; const std::string newNativeViewHandle = newViewProps->nativeViewHandle; diff --git a/packages/react-native/plugin/withNativeScriptReactNative.js b/packages/react-native/plugin/withNativeScriptReactNative.js index e74cc5e22..8f3d78349 100644 --- a/packages/react-native/plugin/withNativeScriptReactNative.js +++ b/packages/react-native/plugin/withNativeScriptReactNative.js @@ -11,22 +11,36 @@ const DEFAULTS = { const BABEL_PLUGIN = '@nativescript/react-native/babel-plugin'; const WORKLETS_BABEL_PLUGIN = 'react-native-worklets/plugin'; const BABEL_PLUGINS = [BABEL_PLUGIN, WORKLETS_BABEL_PLUGIN]; +const EXPO_BABEL_PRESET = 'babel-preset-expo'; +// Without strictGlobal the worklets plugin captures every undeclared name into +// the worklet's closure on the JS thread when the worklet is created, where +// NativeScript's classes (UIView, UILabel, ...) do not exist yet. +const WORKLETS_BABEL_PLUGIN_ENTRY = `['${WORKLETS_BABEL_PLUGIN}', { strictGlobal: true }]`; +// babel-preset-expo adds its own worklets plugin, without strictGlobal. +const EXPO_BABEL_PRESET_ENTRY = `['${EXPO_BABEL_PRESET}', { worklets: false }]`; const METADATA_CONFIG_FILE = 'nativescript.react-native.json'; function readBoolean(value, fallback) { return typeof value === 'boolean' ? value : fallback; } +function stringList(value) { + return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : undefined; +} + function normalizeMetadataOptions(options = {}) { const metadata = options.metadata || {}; - return { - includePods: Array.isArray(metadata.includePods) - ? metadata.includePods.filter((value) => typeof value === 'string') - : [], - includeSystemFrameworks: Array.isArray(metadata.includeSystemFrameworks) - ? metadata.includeSystemFrameworks.filter((value) => typeof value === 'string') - : [], + const normalized = { + includePods: stringList(metadata.includePods) || [], + includeSystemFrameworks: stringList(metadata.includeSystemFrameworks) || [], }; + // Omitted unless given, so ensureMetadataConfig keeps an `android` block + // written by hand into nativescript.react-native.json. + const androidInclude = stringList(metadata.android && metadata.android.include); + if (androidInclude) { + normalized.android = {include: androidInclude}; + } + return normalized; } function normalizeOptions(options = {}) { @@ -60,11 +74,16 @@ function ensureMetadataConfig(projectRoot, metadataOptions) { } } + const existingReactNative = existing.reactNative || {}; + const existingMetadata = + existingReactNative.metadata && typeof existingReactNative.metadata === 'object' + ? existingReactNative.metadata + : {}; const merged = { ...existing, reactNative: { - ...(existing.reactNative || {}), - metadata: metadataOptions, + ...existingReactNative, + metadata: {...existingMetadata, ...metadataOptions}, }, }; const nextSource = `${JSON.stringify(merged, null, 2)}\n`; @@ -74,6 +93,19 @@ function ensureMetadataConfig(projectRoot, metadataOptions) { return merged; } +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); +} + +// Replaces `'name'` written as a bare array element with `entry`. Elements +// already given options (`['name', {...}]`) are left alone. +function replaceBareEntry(source, name, entry) { + const bare = new RegExp( + `([\\[,]\\s*)(['"])${escapeRegExp(name)}\\2(?=\\s*(?:,(?!\\s*\\{)|\\]))`, + ); + return source.replace(bare, (match, prefix) => `${prefix}${entry}`); +} + function ensureBabelPlugin(projectRoot) { const fs = require('fs'); const path = require('path'); @@ -90,8 +122,8 @@ function ensureBabelPlugin(projectRoot) { 'module.exports = function(api) {', ' api.cache(true);', ' return {', - " presets: ['babel-preset-expo'],", - ` plugins: [${BABEL_PLUGINS.map((plugin) => `'${plugin}'`).join(', ')}],`, + ` presets: [${EXPO_BABEL_PRESET_ENTRY}],`, + ` plugins: ['${BABEL_PLUGIN}', ${WORKLETS_BABEL_PLUGIN_ENTRY}],`, ' };', '};', '', @@ -100,37 +132,50 @@ function ensureBabelPlugin(projectRoot) { return; } - let source = fs.readFileSync(babelConfigPath, 'utf8'); + const original = fs.readFileSync(babelConfigPath, 'utf8'); + let source = original; const missingPlugins = BABEL_PLUGINS.filter((plugin) => !source.includes(plugin)); - if (missingPlugins.length === 0) { - return; + if (missingPlugins.length > 0) { + const pluginEntry = missingPlugins + .map((plugin) => + plugin === WORKLETS_BABEL_PLUGIN ? WORKLETS_BABEL_PLUGIN_ENTRY : `'${plugin}'`, + ) + .join(', ') + ', '; + if (/plugins\s*:\s*\[/.test(source)) { + source = source.replace(/plugins\s*:\s*\[/, (match) => `${match}${pluginEntry}`); + } else if (/return\s*\{/.test(source)) { + source = source.replace( + /return\s*\{/, + (match) => `${match}\n plugins: [${pluginEntry}],`, + ); + } else if (/module\.exports\s*=\s*\{/.test(source)) { + source = source.replace( + /module\.exports\s*=\s*\{/, + (match) => `${match}\n plugins: [${pluginEntry}],`, + ); + } else if (/export\s+default\s+\{/.test(source)) { + source = source.replace( + /export\s+default\s+\{/, + (match) => `${match}\n plugins: [${pluginEntry}],`, + ); + } else { + source += `\n// @nativescript/react-native: add ${missingPlugins.map((plugin) => `'${plugin}'`).join(' and ')} to your Babel plugins.\n`; + } } - const pluginEntry = missingPlugins - .map((plugin) => `'${plugin}'`) - .join(', ') + ', '; - if (/plugins\s*:\s*\[/.test(source)) { - source = source.replace(/plugins\s*:\s*\[/, (match) => `${match}${pluginEntry}`); - } else if (/return\s*\{/.test(source)) { - source = source.replace( - /return\s*\{/, - (match) => `${match}\n plugins: [${pluginEntry}],`, - ); - } else if (/module\.exports\s*=\s*\{/.test(source)) { - source = source.replace( - /module\.exports\s*=\s*\{/, - (match) => `${match}\n plugins: [${pluginEntry}],`, - ); - } else if (/export\s+default\s+\{/.test(source)) { - source = source.replace( - /export\s+default\s+\{/, - (match) => `${match}\n plugins: [${pluginEntry}],`, + source = replaceBareEntry(source, WORKLETS_BABEL_PLUGIN, WORKLETS_BABEL_PLUGIN_ENTRY); + source = replaceBareEntry(source, EXPO_BABEL_PRESET, EXPO_BABEL_PRESET_ENTRY); + + if (source.includes(WORKLETS_BABEL_PLUGIN) && !/strictGlobal\s*:\s*true/.test(source)) { + console.warn( + `@nativescript/react-native: pass { strictGlobal: true } to '${WORKLETS_BABEL_PLUGIN}' ` + + `in ${path.basename(babelConfigPath)}, or worklets that name native classes fail as their module loads.`, ); - } else { - source += `\n// @nativescript/react-native: add ${missingPlugins.map((plugin) => `'${plugin}'`).join(' and ')} to your Babel plugins.\n`; } - fs.writeFileSync(babelConfigPath, source); + if (source !== original) { + fs.writeFileSync(babelConfigPath, source); + } } function withNativeScriptBabelPlugin(config) { @@ -141,13 +186,11 @@ function withNativeScriptBabelPlugin(config) { return config; } - return withDangerousMod(config, [ - 'ios', - async (modConfig) => { - ensureBabelPlugin(modConfig.modRequest.projectRoot); - return modConfig; - }, - ]); + const mod = async (modConfig) => { + ensureBabelPlugin(modConfig.modRequest.projectRoot); + return modConfig; + }; + return withDangerousMod(withDangerousMod(config, ['ios', mod]), ['android', mod]); } function withNativeScriptMetadata(config, metadataOptions) { @@ -158,13 +201,11 @@ function withNativeScriptMetadata(config, metadataOptions) { return config; } - return withDangerousMod(config, [ - 'ios', - async (modConfig) => { - ensureMetadataConfig(modConfig.modRequest.projectRoot, metadataOptions); - return modConfig; - }, - ]); + const mod = async (modConfig) => { + ensureMetadataConfig(modConfig.modRequest.projectRoot, metadataOptions); + return modConfig; + }; + return withDangerousMod(withDangerousMod(config, ['ios', mod]), ['android', mod]); } /** diff --git a/packages/react-native/src/NativeScriptNativeApi.ts b/packages/react-native/src/NativeScriptNativeApi.ts index 4cb54f02e..ca8da1259 100644 --- a/packages/react-native/src/NativeScriptNativeApi.ts +++ b/packages/react-native/src/NativeScriptNativeApi.ts @@ -14,4 +14,7 @@ export interface Spec extends TurboModule { readonly __writeTestMarker: (content: string) => boolean; } -export default TurboModuleRegistry.getEnforcing('NativeScriptNativeApi'); +// `get`, not `getEnforcing`: a build without the native module (Expo Go, or a +// build made before the package was added) must still be able to import the +// package and decide what to do without it. +export default TurboModuleRegistry.get('NativeScriptNativeApi'); diff --git a/packages/react-native/src/index.d.ts b/packages/react-native/src/index.d.ts index 3e15e684d..818bacaa7 100644 --- a/packages/react-native/src/index.d.ts +++ b/packages/react-native/src/index.d.ts @@ -273,6 +273,12 @@ export type UIViewControllerDefinition< export function init(metadataPath?: string, options?: InstallOptions): boolean; export const install: typeof init; export function installGlobals(): boolean; +/** + * Whether this build contains the package's native module. False in Expo Go + * and in builds made before the package was added; every other function that + * needs the module throws there. + */ +export function isAvailable(): boolean; export function isInstalled(): boolean; export function defaultMetadataPath(): string; export function getRuntimeBackend(): string; @@ -280,6 +286,12 @@ export function installWorklets( worklets?: NativeScriptWorklets, metadataPath?: string, ): boolean; +/** + * Runs `callback` on the UI thread, where views can be touched. On iOS it must + * be a worklet, and runs on the Worklets UI runtime. On Android it runs on the + * main thread in the same runtime, while the JavaScript thread waits; Java + * callbacks the main thread makes, such as a view's listeners, run there too. + */ export function runOnUI( callback: (...args: Args) => ReturnValue | Promise, ...args: Args @@ -299,6 +311,17 @@ export function eventBridge any>( ): T | NativeScriptInvokedCallback; export const createEventBridge: typeof eventBridge; export function isMainThread(): boolean; +/** + * The Context to build an Android view with: the current Activity, or the + * application before there is one. Null on iOS. + */ +export function androidContext(): unknown; +/** + * The string a `NativeScriptUIView` takes as `nativeViewHandle` to show `view`: + * a UIView on iOS (call it on the UI runtime), an android.view.View on Android. + * The handle does not keep the view alive; hold the view until a host shows it. + */ +export function nativeViewHandle(view: unknown): string; export function assertUIKitThread(message?: string): void; export function refreshUIKitHostView(view: unknown): boolean; export function refreshUIKitHostViewHandle(viewHandle: string): boolean; @@ -339,9 +362,11 @@ export function defineUIViewController< ): UIKitViewComponent; declare const NativeScript: { + androidContext: typeof androidContext; init: typeof init; install: typeof install; installGlobals: typeof installGlobals; + isAvailable: typeof isAvailable; isInstalled: typeof isInstalled; defaultMetadataPath: typeof defaultMetadataPath; defineUIKitContainer: typeof defineUIKitContainer; @@ -361,6 +386,7 @@ declare const NativeScript: { isMainThread: typeof isMainThread; jsInvoker: typeof jsInvoker; loadFramework: typeof loadFramework; + nativeViewHandle: typeof nativeViewHandle; release: typeof release; retain: typeof retain; refreshUIKitHostView: typeof refreshUIKitHostView; diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index b15d01120..466d98a72 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -12,7 +12,9 @@ import type { RefAttributes, } from "react"; import type { ViewProps } from "react-native"; +import { Platform } from "react-native"; import NativeScriptNativeApi from "./NativeScriptNativeApi"; +import type { Spec as NativeScriptNativeApiSpec } from "./NativeScriptNativeApi"; import NativeScriptUIViewNativeComponent from "./NativeScriptUIViewNativeComponent"; declare const require: (id: string) => any; @@ -906,6 +908,26 @@ function installInlineFunctions(): void { ); } +// Only full C constant names (`NSTextAlignmentCenter`, `kCLAuthorizationStatus...`) +// become globals. The enum object also carries short member names (`Center`, +// CloudKit's `Zone`), which as globals shadow what libraries feature-detect. +function isEnumMemberGlobalName(enumName: string, memberName: string): boolean { + if (/^-?\d+$/.test(memberName)) { + return false; + } + if (/^k[A-Z]/.test(memberName)) { + return true; + } + const prefix = /^[A-Z]+(?=[A-Z][a-z])/.exec(enumName)?.[0]; + return ( + prefix !== undefined && + prefix.length >= 2 && + memberName.length > prefix.length && + memberName.startsWith(prefix) && + /[A-Z]/.test(memberName[prefix.length]) + ); +} + export function installGlobals(): boolean { const api = nativeApiHost(); if (!api) { @@ -948,7 +970,7 @@ export function installGlobals(): boolean { continue; } for (const memberName of Object.keys(enumValue)) { - if (/^-?\d+$/.test(memberName)) { + if (!isEnumMemberGlobalName(name, memberName)) { continue; } defineLazyNativeGlobal( @@ -983,10 +1005,38 @@ export function installGlobals(): boolean { return true; } +function nativeModule(): NativeScriptNativeApiSpec { + if (NativeScriptNativeApi == null) { + throw new Error( + "@nativescript/react-native's native module (NativeScriptNativeApi) is not in this build. " + + "The package contains native code, so Expo Go cannot load it: install it in a development " + + "build and rebuild the app (npx expo run:ios / run:android, or EAS Build).", + ); + } + return NativeScriptNativeApi; +} + +// Android has no Worklets runtime to install into (see installWorkletRuntime in +// NativeScriptNativeApiModule.java): runOnUI runs on its main thread instead. +function supportsWorkletRuntime(): boolean { + return Platform.OS === "ios"; +} + +function assertWorkletRuntimeSupported(): void { + if (!supportsWorkletRuntime()) { + throw new Error( + `NativeScript does not install into Worklets on ${Platform.OS}: there is no UI runtime to install into. runOnUI runs on the main thread there without one.`, + ); + } +} + +export function isAvailable(): boolean { + return NativeScriptNativeApi != null; +} + export function init(metadataPath = "", options: InstallOptions = {}): boolean { - const installed = - NativeScriptNativeApi.isInstalled() || - NativeScriptNativeApi.install(metadataPath); + const api = nativeModule(); + const installed = api.isInstalled() || api.install(metadataPath); if (installed) { installInteropConstructors(); installInlineFunctions(); @@ -994,7 +1044,7 @@ export function init(metadataPath = "", options: InstallOptions = {}): boolean { if (installed && options.globals === true) { installGlobals(); } - if (installed) { + if (installed && supportsWorkletRuntime()) { ensureWorkletsInstalled(metadataPath); } return installed; @@ -1003,15 +1053,15 @@ export function init(metadataPath = "", options: InstallOptions = {}): boolean { export const install = init; export function isInstalled(): boolean { - return NativeScriptNativeApi.isInstalled(); + return NativeScriptNativeApi != null && NativeScriptNativeApi.isInstalled(); } export function defaultMetadataPath(): string { - return NativeScriptNativeApi.defaultMetadataPath(); + return nativeModule().defaultMetadataPath(); } export function getRuntimeBackend(): string { - return NativeScriptNativeApi.getRuntimeBackend(); + return nativeModule().getRuntimeBackend(); } let workletsAdapter: NativeScriptWorklets | undefined; @@ -1292,16 +1342,19 @@ function ensureWorkletsInstalled(metadataPath = ""): NativeScriptWorklets { if (workletsAdapter) { return workletsAdapter; } + assertWorkletRuntimeSupported(); installWorklets(requireReactNativeWorklets(), metadataPath); return workletsAdapter as NativeScriptWorklets; } export function installWorklets( - worklets: NativeScriptWorklets = requireReactNativeWorklets(), + worklets?: NativeScriptWorklets, metadataPath = "", ): boolean { - if (!NativeScriptNativeApi.isInstalled()) { - const installed = NativeScriptNativeApi.install(metadataPath); + const api = nativeModule(); + assertWorkletRuntimeSupported(); + if (!api.isInstalled()) { + const installed = api.install(metadataPath); if (!installed) { throw new Error( "NativeScript Native API JSI host object was not installed", @@ -1311,14 +1364,16 @@ export function installWorklets( installInlineFunctions(); } - const validWorklets = validateWorkletsModule(worklets); + const validWorklets = validateWorkletsModule( + worklets ?? requireReactNativeWorklets(), + ); const holder = validWorklets.getUIRuntimeHolder(); if (holder == null || typeof holder !== "object") { throw workletsSetupError( "NativeScript.runOnUI could not resolve a Worklets UI runtime", ); } - const installRuntime = NativeScriptNativeApi.installWorkletRuntime; + const installRuntime = api.installWorkletRuntime; if (typeof installRuntime !== "function") { throw workletsSetupError( "NativeScript Native API was built without RNWorklets runtime support", @@ -1346,6 +1401,9 @@ export function runOnUI( } ensureNativeScriptInstalled(); + if (Platform.OS === "android") { + return runOnAndroidMainThread(callback, args); + } const worklets = ensureWorkletsInstalled(); if (worklets.isWorkletFunction(callback) !== true) { throw workletsSetupError( @@ -1355,6 +1413,28 @@ export function runOnUI( return worklets.runOnUIAsync(callback, ...args); } +// One runtime, so arguments and captured values are the caller's own rather +// than copies. The JS thread waits while the main thread runs the callback. +function runOnAndroidMainThread( + callback: (...args: Args) => ReturnValue | Promise, + args: Args, +): Promise { + const runOnMainThread = (globalThis as Record) + .__nativeScriptRunOnMainThread; + if (typeof runOnMainThread !== "function") { + return Promise.reject( + new Error( + "This build of NativeScript cannot run on the Android main thread; rebuild the app", + ), + ); + } + try { + return Promise.resolve(runOnMainThread(() => callback(...args))); + } catch (error) { + return Promise.reject(error); + } +} + function callbackInvoker( thread: NativeScriptCallbackThread, callback: T, @@ -1512,8 +1592,28 @@ export const createEventBridge = eventBridge; export function isMainThread(): boolean { "worklet"; - const NSThread = (globalThis as Record).NSThread; - return NSThread?.isMainThread === true; + const g = globalThis as Record; + if (typeof g.__nativeScriptIsMainThread === "function") { + return g.__nativeScriptIsMainThread() === true; + } + return g.NSThread?.isMainThread === true; +} + +export function androidContext(): unknown { + const context = (globalThis as Record) + .__nativeScriptAndroidContext; + return typeof context === "function" ? context() : null; +} + +export function nativeViewHandle(view: unknown): string { + "worklet"; + + const androidViewHandle = (globalThis as Record) + .__nativeScriptAndroidViewHandle; + if (typeof androidViewHandle === "function") { + return androidViewHandle(view); + } + return nativeHandleForUIKitView(view); } export function assertUIKitThread( @@ -3231,9 +3331,11 @@ export function defineUIViewController< } const NativeScript = { + androidContext, init, install, installGlobals, + isAvailable, isInstalled, defaultMetadataPath, defineUIKitContainer, @@ -3252,6 +3354,7 @@ const NativeScript = { isFrameworkLoaded, isMainThread, jsInvoker, + nativeViewHandle, loadFramework, release, retain, diff --git a/packages/react-native/test/android-main-thread-api.test.js b/packages/react-native/test/android-main-thread-api.test.js new file mode 100644 index 000000000..6f3ac7561 --- /dev/null +++ b/packages/react-native/test/android-main-thread-api.test.js @@ -0,0 +1,162 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const { stripTypeScriptTypes } = require("node:module"); + +const packageRoot = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(packageRoot, "../.."); + +function read(relativePath, root = packageRoot) { + return fs.readFileSync(path.join(root, relativePath), "utf8"); +} + +const index = read("src/index.ts"); + +function extract(name) { + const match = new RegExp( + `(?:export )?function ${name}(?:<[^>]*>)?\\([\\s\\S]*?\\n\\}\\n`, + ).exec(index); + assert(match, `index.ts should define ${name}`); + return stripTypeScriptTypes(match[0].replace(/^export /, "")); +} + +const load = (names, globals) => + new Function( + "globalThis", + `${names.map(extract).join("\n")}; return { ${names.join(", ")} };`, + )(globals); + +(async () => { + // runOnUI on Android: the callback gets its arguments, and a throw rejects. + { + const calls = []; + const globals = { + __nativeScriptRunOnMainThread(fn) { + calls.push("main"); + return fn(); + }, + }; + const { runOnAndroidMainThread } = load(["runOnAndroidMainThread"], globals); + assert.strictEqual(await runOnAndroidMainThread((a, b) => a + b, [2, 3]), 5); + await assert.rejects( + runOnAndroidMainThread(() => { + throw new Error("boom"); + }, []), + /boom/, + ); + assert.deepStrictEqual(calls, ["main", "main"]); + } + + // A build without the native half rejects rather than running on the JS thread. + { + const { runOnAndroidMainThread } = load(["runOnAndroidMainThread"], {}); + let ran = false; + await assert.rejects( + runOnAndroidMainThread(() => { + ran = true; + }, []), + /rebuild the app/, + ); + assert.strictEqual(ran, false); + } + + assert( + /ensureNativeScriptInstalled\(\);\s*if \(Platform\.OS === "android"\) \{\s*return runOnAndroidMainThread\(callback, args\);/.test( + index, + ), + "runOnUI should take the Android path before requiring Worklets", + ); + + // nativeViewHandle and isMainThread prefer the Android globals, else iOS's. + { + const android = load(["nativeHandleForUIKitView", "nativeViewHandle", "isMainThread"], { + __nativeScriptAndroidViewHandle: (view) => `android:${view.id}`, + __nativeScriptIsMainThread: () => true, + }); + assert.strictEqual(android.nativeViewHandle({ id: 7 }), "android:7"); + assert.strictEqual(android.isMainThread(), true); + + const ios = load(["nativeHandleForUIKitView", "nativeViewHandle", "isMainThread"], { + interop: { handleof: () => ({ toHexString: () => "0x10" }) }, + NSThread: { isMainThread: false }, + }); + assert.strictEqual(ios.nativeViewHandle({}), "0x10"); + assert.strictEqual(ios.isMainThread(), false); + } + + { + const context = {}; + assert.strictEqual( + load(["androidContext"], { __nativeScriptAndroidContext: () => context }).androidContext(), + context, + ); + assert.strictEqual(load(["androidContext"], {}).androidContext(), null); + } + + const declarations = read("src/index.d.ts"); + for (const name of ["nativeViewHandle", "androidContext"]) { + assert( + declarations.includes(`export function ${name}(`) && + declarations.includes(`${name}: typeof ${name};`), + `index.d.ts should declare ${name} and put it on the default export`, + ); + } + + // The globals index.ts reads are the ones the guest installs. + const install = read("android/src/main/cpp/NativeScriptRnInstall.cpp"); + for (const name of [ + "__nativeScriptRunOnMainThread", + "__nativeScriptIsMainThread", + "__nativeScriptAndroidViewHandle", + "__nativeScriptAndroidContext", + ]) { + assert(install.includes(`"${name}"`), `NativeScriptRnInstall.cpp should install ${name}`); + assert(index.includes(name), `index.ts should read ${name}`); + } + + // Each guest-only native com.tns.Runtime declares has its JNI symbol here. + const runtimeJava = read( + "platforms/android/test-app/runtime/src/main/java/com/tns/Runtime.java", + repoRoot, + ); + for (const name of [ + "runMainThreadTaskNative", + "releaseEngineLockNative", + "reacquireEngineLockNative", + "drainMicrotasksNative", + ]) { + assert(runtimeJava.includes(`private native void ${name}(`) || + runtimeJava.includes(`private native int ${name}(`), + `Runtime.java should declare ${name}`); + assert(install.includes(`Java_com_tns_Runtime_${name}`), `${name} should be implemented`); + } + assert( + /private void runOnMainThread\(long task\)/.test(runtimeJava) && + install.includes('"runOnMainThread", "(J)V"'), + "the guest should call Runtime.runOnMainThread(long)", + ); + + // NativeScriptUIView has an Android view manager, registered by the package. + const packageJava = read( + "android/src/main/java/org/nativescript/reactnative/NativeScriptNativeApiPackage.java", + ); + assert(packageJava.includes("new NativeScriptUIViewManager()")); + const manager = read( + "android/src/main/java/org/nativescript/reactnative/NativeScriptUIViewManager.java", + ); + assert(manager.includes('NAME = "NativeScriptUIView"')); + const registry = read( + "android/src/main/java/org/nativescript/reactnative/NativeScriptViewRegistry.java", + ); + assert( + install.includes('"handleFor"') && registry.includes("public static synchronized String handleFor(Object object)"), + ); + assert( + install.includes('"viewContext"') && registry.includes("public static synchronized Context viewContext()"), + ); + + console.log("android-main-thread-api tests passed"); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/react-native/test/config-plugin.test.js b/packages/react-native/test/config-plugin.test.js index 3346c0f61..b63c00626 100644 --- a/packages/react-native/test/config-plugin.test.js +++ b/packages/react-native/test/config-plugin.test.js @@ -80,6 +80,85 @@ withTempProject((projectRoot) => { ); }); +function loadBabelConfig(projectRoot) { + const configPath = path.join(projectRoot, 'babel.config.js'); + delete require.cache[require.resolve(configPath)]; + const exported = require(configPath); + return typeof exported === 'function' ? exported({cache() {}}) : exported; +} + +function findEntry(entries, name) { + return (entries || []).find( + (entry) => entry === name || (Array.isArray(entry) && entry[0] === name), + ); +} + +withTempProject((projectRoot) => { + ensureBabelPlugin(projectRoot); + const config = loadBabelConfig(projectRoot); + assert.deepStrictEqual(findEntry(config.presets, 'babel-preset-expo'), [ + 'babel-preset-expo', + {worklets: false}, + ]); + assert.deepStrictEqual(findEntry(config.plugins, 'react-native-worklets/plugin'), [ + 'react-native-worklets/plugin', + {strictGlobal: true}, + ]); +}); + +withTempProject((projectRoot) => { + fs.writeFileSync( + path.join(projectRoot, 'babel.config.js'), + [ + 'module.exports = function(api) {', + ' api.cache(true);', + ' return {', + " presets: ['babel-preset-expo'],", + " plugins: ['@nativescript/react-native/babel-plugin', 'react-native-worklets/plugin'],", + ' };', + '};', + '', + ].join('\n'), + ); + ensureBabelPlugin(projectRoot); + ensureBabelPlugin(projectRoot); + const config = loadBabelConfig(projectRoot); + assert.deepStrictEqual(findEntry(config.presets, 'babel-preset-expo'), [ + 'babel-preset-expo', + {worklets: false}, + ]); + assert.deepStrictEqual(findEntry(config.plugins, 'react-native-worklets/plugin'), [ + 'react-native-worklets/plugin', + {strictGlobal: true}, + ]); + assert.strictEqual(config.plugins.length, 2); +}); + +withTempProject((projectRoot) => { + const source = [ + 'module.exports = {', + " plugins: [['react-native-worklets/plugin', { processNestedWorklets: true }]],", + '};', + '', + ].join('\n'); + fs.writeFileSync(path.join(projectRoot, 'babel.config.js'), source); + const warnings = []; + const warn = console.warn; + console.warn = (message) => warnings.push(message); + try { + ensureBabelPlugin(projectRoot); + } finally { + console.warn = warn; + } + const config = loadBabelConfig(projectRoot); + assert.deepStrictEqual(findEntry(config.plugins, 'react-native-worklets/plugin'), [ + 'react-native-worklets/plugin', + {processNestedWorklets: true}, + ]); + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0], /strictGlobal/); +}); + withTempProject((projectRoot) => { const metadata = normalizeMetadataOptions({ metadata: { @@ -98,4 +177,44 @@ withTempProject((projectRoot) => { }); }); +withTempProject((projectRoot) => { + const configPath = path.join(projectRoot, 'nativescript.react-native.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + reactNative: { + metadata: { + includePods: ['StalePod'], + android: {include: ['com.example.Worker']}, + }, + }, + }), + ); + ensureMetadataConfig(projectRoot, normalizeMetadataOptions({})); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + assert.deepStrictEqual(config.reactNative.metadata, { + includePods: [], + includeSystemFrameworks: [], + android: {include: ['com.example.Worker']}, + }); +}); + +withTempProject((projectRoot) => { + const configPath = path.join(projectRoot, 'nativescript.react-native.json'); + fs.writeFileSync( + configPath, + JSON.stringify({reactNative: {metadata: {android: {include: ['com.example.Old']}}}}), + ); + ensureMetadataConfig( + projectRoot, + normalizeMetadataOptions({ + metadata: {android: {include: ['com.example.Worker', 7, 'com.example.views.*']}}, + }), + ); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + assert.deepStrictEqual(config.reactNative.metadata.android, { + include: ['com.example.Worker', 'com.example.views.*'], + }); +}); + console.log('config plugin tests passed'); diff --git a/packages/react-native/test/enum-member-globals.test.js b/packages/react-native/test/enum-member-globals.test.js new file mode 100644 index 000000000..6b7bc6aa8 --- /dev/null +++ b/packages/react-native/test/enum-member-globals.test.js @@ -0,0 +1,62 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repoRoot = path.resolve(__dirname, "../../.."); +const installSource = fs.readFileSync( + path.join(repoRoot, "NativeScript/ffi/objc/shared/bridge/Install.mm"), + "utf8", +); +const index = fs.readFileSync(path.join(__dirname, "../src/index.ts"), "utf8"); + +const match = /function isEnumMemberGlobalName\(enumName, memberName\) \{[\s\S]*?\n \}\n/.exec( + installSource, +); +assert(match, "Install.mm should define isEnumMemberGlobalName"); +const isEnumMemberGlobalName = new Function( + `${match[0]}; return isEnumMemberGlobalName;`, +)(); + +const cases = [ + ["NSTextAlignment", "Center", false], + ["NSTextAlignment", "NSTextAlignmentCenter", true], + ["NSTextAlignment", "kNSTextAlignmentCenter", true], + ["NSTextAlignment", "1", false], + ["CKRecordZoneCapabilities", "Zone", false], + ["NSComparisonResult", "OrderedAscending", false], + ["NSComparisonResult", "NSOrderedAscending", true], + ["CLAuthorizationStatus", "kCLAuthorizationStatusRestricted", true], + ["NSControlStateValue", "NSControlStateValueOn", true], + ["UIViewAutoresizing", "FlexibleWidth", false], + ["UIViewAutoresizing", "UIViewAutoresizingFlexibleWidth", true], + ["NSURLErrorCode", "NSURLErrorCancelled", true], + ["MTLPixelFormat", "Fill", false], +]; +for (const [enumName, memberName, expected] of cases) { + assert.strictEqual( + isEnumMemberGlobalName(enumName, memberName), + expected, + `${enumName}.${memberName} as a global`, + ); +} + +const tsMatch = /function isEnumMemberGlobalName\(enumName: string, memberName: string\): boolean \{[\s\S]*?\n\}\n/.exec( + index, +); +assert(tsMatch, "index.ts should define isEnumMemberGlobalName"); +const isEnumMemberGlobalNameTs = new Function( + `${tsMatch[0].replace("(enumName: string, memberName: string): boolean", "(enumName, memberName)")}; return isEnumMemberGlobalName;`, +)(); +for (const [enumName, memberName, expected] of cases) { + assert.strictEqual( + isEnumMemberGlobalNameTs(enumName, memberName), + expected, + `${enumName}.${memberName} as a global (index.ts)`, + ); +} +assert( + /function installGlobals\(\)[\s\S]*?isEnumMemberGlobalName\(name, memberName\)/.test(index), + "installGlobals should filter enum members through isEnumMemberGlobalName", +); + +console.log("enum member global tests passed"); diff --git a/platforms/android/test-app/runtime/src/main/java/com/tns/MainThreadLoan.java b/platforms/android/test-app/runtime/src/main/java/com/tns/MainThreadLoan.java new file mode 100644 index 000000000..36f29d02e --- /dev/null +++ b/platforms/android/test-app/runtime/src/main/java/com/tns/MainThreadLoan.java @@ -0,0 +1,202 @@ +package com.tns; + +import android.os.Handler; +import android.os.Looper; + +/* + Lets the main thread run JavaScript in a runtime attached to a host (React + Native), whose JavaScript thread is not the main thread. + + A jsi::Runtime may be entered from any thread as long as one thread at a + time is in it. The main thread asks the JavaScript thread to stop where it is + holding nothing mid-flight -- between two of its looper messages, or blocked + in runOnMainThread -- and runs while that thread waits. React Native's + executeSynchronouslyOnSameThread_CAN_DEADLOCK hands its runtime over the + same way. + + Only the main thread borrows. Other threads keep posting to the JavaScript + thread and waiting, as before. + */ +final class MainThreadLoan { + interface Body { + T run() throws NativeScriptException; + } + + private interface Condition { + boolean isMet(); + } + + private static final Condition ALWAYS = () -> true; + + private final Runtime runtime; + private final ThreadScheduler jsThread; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + private final Object lock = new Object(); + // Guarded by lock. + private boolean jsThreadWaiting; + // Guarded by lock. The JavaScript thread stopped between looper messages, + // so React Native has drained its microtasks and is in no task of its own. + private boolean jsThreadAtBoundary; + private boolean mainWaiting; + private boolean mainRunning; + private boolean parkPosted; + private boolean closed; + + // Main thread only. + private int depth; + + MainThreadLoan(Runtime runtime, ThreadScheduler jsThread) { + this.runtime = runtime; + this.jsThread = jsThread; + } + + static boolean isMainThread() { + return Looper.myLooper() == Looper.getMainLooper(); + } + + /* + Runs `body` on the calling (main) thread with the JavaScript thread + stopped. Re-entrant. Answers null without running `body` once the + runtime is gone, as a call posted to a stopped JavaScript thread does. + */ + T borrow(Body body) throws NativeScriptException { + if (depth > 0) { + depth++; + try { + return body.run(); + } finally { + depth--; + } + } + + boolean atBoundary; + synchronized (lock) { + mainWaiting = true; + boolean interrupted = false; + while (!jsThreadWaiting && !closed) { + if (!parkPosted) { + parkPosted = jsThread.post(this::park); + if (!parkPosted) { + closed = true; + break; + } + } + try { + lock.wait(); + } catch (InterruptedException e) { + interrupted = true; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + mainWaiting = false; + if (closed) { + lock.notifyAll(); + return null; + } + mainRunning = true; + atBoundary = jsThreadAtBoundary; + } + + depth = 1; + // A Java object JavaScript constructs here (a listener, a subclass) + // registers with the current runtime. + Runtime previous = runtime.enterOnThisThread(); + try { + return body.run(); + } finally { + try { + // Nothing else will run what this call queued until React Native + // next dispatches a task, and the JavaScript thread can sit idle. + // Mid-task, React Native drains them itself when the task ends. + if (atBoundary) { + runtime.drainMicrotasks(); + } + } finally { + Runtime.restoreOnThisThread(previous); + depth = 0; + synchronized (lock) { + mainRunning = false; + lock.notifyAll(); + } + } + } + } + + /* + Called on the JavaScript thread: runs the native task on the main thread + and returns once it has run, lending the runtime meanwhile. + */ + void runOnMainThread(final long task) { + final boolean[] done = {false}; + final Runnable onMain = () -> { + try { + borrow(() -> { + runtime.runMainThreadTask(task); + return null; + }); + } finally { + synchronized (lock) { + done[0] = true; + lock.notifyAll(); + } + } + }; + lend(false, () -> done[0], () -> { + if (!mainHandler.post(onMain)) { + done[0] = true; + } + }); + } + + /* Wakes a main thread waiting on a JavaScript thread that will never stop for it. */ + void close() { + synchronized (lock) { + closed = true; + lock.notifyAll(); + } + } + + private void park() { + synchronized (lock) { + parkPosted = false; + if (!mainWaiting) { + return; + } + } + lend(true, ALWAYS, null); + } + + private void lend(boolean atBoundary, Condition finished, Runnable start) { + // The JavaScript thread can be inside a Java->JS call, holding the engine + // lock the borrower's calls take. It owns nothing while parked. + int engineLockDepth = runtime.releaseEngineLock(); + try { + synchronized (lock) { + jsThreadWaiting = true; + jsThreadAtBoundary = atBoundary; + lock.notifyAll(); + if (start != null) { + start.run(); + } + boolean interrupted = false; + while (!finished.isMet() || mainWaiting || mainRunning) { + try { + lock.wait(); + } catch (InterruptedException e) { + interrupted = true; + } + } + jsThreadWaiting = false; + jsThreadAtBoundary = false; + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } finally { + runtime.reacquireEngineLock(engineLockDepth); + } + } +} diff --git a/platforms/android/test-app/runtime/src/main/java/com/tns/Runtime.java b/platforms/android/test-app/runtime/src/main/java/com/tns/Runtime.java index 739c10824..acab8c1f9 100644 --- a/platforms/android/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/platforms/android/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -53,6 +53,18 @@ private native void attachNativeScript(int runtimeId, String metadataPath, long boolean verboseLoggingEnabled, int maxLogcatObjectSize, boolean forceLog, String bootstrapScript); + // Implemented alongside attachNativeScript; see detach(). + private native void detachNativeScript(int runtimeId); + + // Guest only, implemented alongside attachNativeScript; see MainThreadLoan. + private native void runMainThreadTaskNative(int runtimeId, long task); + + private native int releaseEngineLockNative(int runtimeId); + + private native void reacquireEngineLockNative(int runtimeId, int depth); + + private native void drainMicrotasksNative(int runtimeId); + private native void runModule(int runtimeId, String filePath) throws NativeScriptException; private native Object runScript(int runtimeId, String filePath) throws NativeScriptException; @@ -142,6 +154,12 @@ public static void passSuppressedExceptionToJs(Throwable ex, String methodName) private boolean initialized; + // Read by the GC listener's thread. + private volatile boolean detached; + + // Set when attached to a host runtime, whose JS thread is not the main thread. + private MainThreadLoan mainThreadLoan; + private final static String FAILED_CTOR_RESOLUTION_MSG = "Check the number and type of arguments.\n" + "Primitive types need to be manually wrapped in their respective Object wrappers.\n" + "If you are creating an instance of an inner class, make sure to always provide reference to the outer `this` as the first argument."; @@ -225,6 +243,9 @@ public int compare(Method lhs, Method rhs) { private static AtomicInteger nextRuntimeId = new AtomicInteger(0); private final static ThreadLocal currentRuntime = new ThreadLocal(); private final static Map runtimeCache = new ConcurrentHashMap<>(); + // Kept so a Java callback that outlives its runtime can be told apart from a + // call on an object no runtime ever owned. Only their weak maps are read. + private final static java.util.Set detachedRuntimes = ConcurrentHashMap.newKeySet(); public static boolean nativeLibraryLoaded; private static final ClassStorageService classStorageService = new ClassStorageServiceImpl(ClassCacheImpl.INSTANCE, ClassLoadersCollectionImpl.INSTANCE); @@ -515,11 +536,119 @@ private void attach(long hostRuntimePtr, String metadataPath, String bootstrapSc attachNativeScript(getRuntimeId(), metadataPath, hostRuntimePtr, logger.isEnabled(), maxLogcatObjectSize, forceConsoleLog, bootstrapScript); + mainThreadLoan = new MainThreadLoan(this, threadScheduler); + GcListener.subscribe(this); initialized = true; } + /* + Unbinds from the embedder's runtime, releasing everything held in it. + Must run while that runtime is still alive and not running JS: React + Native's module invalidation is such a point. Idempotent. The instance + cannot be attached again; a new host runtime gets a new Runtime. + */ + public void detach() { + if (!initialized || detached) { + return; + } + detached = true; + + if (mainThreadLoan != null) { + mainThreadLoan.close(); + } + + GcListener.unsubscribe(this); + runtimeCache.remove(runtimeId); + if (currentRuntime.get() == this) { + currentRuntime.remove(); + } + + detachNativeScript(runtimeId); + + // The native side keeps a global reference to this instance, so the + // strong maps would pin every Java object the detached JS ever held. + // Moving the ids to the weak map keeps ownership answerable without that. + for (Map.Entry entry : strongJavaObjectToID.entrySet()) { + weakJavaObjectToID.put(entry.getKey(), entry.getValue()); + } + strongInstances.clear(); + strongJavaObjectToID.clear(); + detachedRuntimes.add(this); + } + + /* + Called by the guest's __nativeScriptRunOnMainThread on the JS thread. + Returns once the main thread has run `task`, a native pointer it hands + back to runMainThreadTaskNative. + */ + @RuntimeCallable + private void runOnMainThread(long task) { + mainThreadLoan.runOnMainThread(task); + } + + /* Makes this the runtime getCurrentRuntime answers on the calling thread; returns the previous one. */ + Runtime enterOnThisThread() { + Runtime previous = currentRuntime.get(); + currentRuntime.set(this); + return previous; + } + + static void restoreOnThisThread(Runtime previous) { + if (previous == null) { + currentRuntime.remove(); + } else { + currentRuntime.set(previous); + } + } + + void runMainThreadTask(long task) { + runMainThreadTaskNative(runtimeId, task); + } + + int releaseEngineLock() { + return releaseEngineLockNative(runtimeId); + } + + void reacquireEngineLock(int depth) { + reacquireEngineLockNative(runtimeId, depth); + } + + void drainMicrotasks() { + drainMicrotasksNative(runtimeId); + } + + private static Runtime getDetachedObjectRuntime(Object object) { + for (Runtime r : detachedRuntimes) { + if (r.getJavaObjectID(object) != null) { + return r; + } + } + return null; + } + + /* + The JavaScript this call targets no longer exists. Generated proxies + unbox a primitive return value, so those get a boxed zero, not null. + */ + private Object dropCallIntoDetached(Object javaObject, String methodName, Class retType) { + android.util.Log.w("TNS.Runtime", "Dropped call to " + javaObject.getClass().getName() + "." + methodName + + "(): the JavaScript runtime it belonged to was reloaded."); + + if (retType == null || !retType.isPrimitive() || retType == void.class) { + return null; + } + if (retType == boolean.class) return Boolean.FALSE; + if (retType == char.class) return Character.valueOf('\0'); + if (retType == byte.class) return Byte.valueOf((byte) 0); + if (retType == short.class) return Short.valueOf((short) 0); + if (retType == int.class) return Integer.valueOf(0); + if (retType == long.class) return Long.valueOf(0L); + if (retType == float.class) return Float.valueOf(0f); + return Double.valueOf(0d); + } + /* Called via JNI from a native-spawned worker thread (WorkerWrapper), after the thread has attached to the JVM and before the worker script runs. @@ -766,11 +895,17 @@ private long getUsedMemory() { } public void notifyGc() { + if (detached) { + return; + } int[] objectIds = new int[0]; notifyGc(runtimeId, objectIds); } public void notifyGc(int[] objectIds) { + if (detached) { + return; + } for (int id: objectIds) { weakInstances.remove(id); } @@ -1225,6 +1360,13 @@ public static Object callJSMethod(int runtimeId, Object javaObject, String metho javaObjectID = (runtime != null) ? runtime.getJavaObjectID(javaObject) : null; } + if (runtime == null && javaObject != null) { + Runtime detachedOwner = getDetachedObjectRuntime(javaObject); + if (detachedOwner != null) { + return detachedOwner.dropCallIntoDetached(javaObject, methodName, retType); + } + } + if (runtime == null) { runtime = Runtime.getCurrentRuntime(); javaObjectID = (runtime != null) ? runtime.getJavaObjectID(javaObject) : null; @@ -1370,6 +1512,20 @@ private Object dispatchCallJSMethodNative(final int javaObjectID, Class claz, throw e; } } + } else if (mainThreadLoan != null && MainThreadLoan.isMainThread()) { + // A view's listener runs where it can touch the view. + ret = mainThreadLoan.borrow(() -> { + try { + return callJSMethodNative(getRuntimeId(), javaObjectID, claz, methodName, returnType, isConstructor, packageArgs(tmpArgs)); + } catch (NativeScriptException e) { + if (discardUncaughtJsExceptions) { + android.util.Log.w("Warning", "NativeScript discarding uncaught JS exception!"); + passExceptionToJS(e, false, true); + return null; + } + throw e; + } + }); } else { final Object[] arr = new Object[2]; @@ -1391,6 +1547,11 @@ public void run() { throw e; } } finally { + // A looper message is no React Native task, so nothing + // else drains what the call queued. + if (mainThreadLoan != null) { + drainMicrotasks(); + } this.notify(); arr[1] = Boolean.TRUE; }