From 74f1123a99185a795873eb927ba1f18547ddc642 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 17 Oct 2026 23:24:35 -0700 Subject: [PATCH 01/14] fix(react-native/ios): attach a recycled NativeScriptUIView's handle when it matches its last one Fabric keeps _props across prepareForRecycle while the container is cleared, so diffing against _props skipped setNativeViewHandle when a recycled host was mounted with the handle it last showed, leaving it empty. Diff against the oldProps argument instead, which is null on a fresh mount. --- .../ios/Fabric/NativeScriptUIViewComponentView.mm | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) 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; From cf2393e645f40018be56e9643c7e990b34e2622e Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 17 Oct 2026 23:25:09 -0700 Subject: [PATCH 02/14] fix(ffi/objc): never release a Value whose runtime has been torn down react-native-worklets shares host objects across runtimes, so a native object returned from runOnUI is finalized by the JS runtime after the UI runtime is gone. Its destructor released the UI bridge's expandos and caches into the dead runtime, crashing every JS reload (EXC_BAD_ACCESS in NativeApiBridge::forgetObjectExpandos during HadesGC::finalizeAll). The bridge's API host object now marks the bridge torn down as its runtime is finalized; from then on Values the bridge drops are leaked. --- .../ffi/objc/shared/bridge/HostObject.mm | 5 ++ .../ffi/objc/shared/bridge/ObjCBridge.mm | 78 ++++++++++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) 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/ObjCBridge.mm b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm index 5baecf609..0385c8e00 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_; } @@ -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 { @@ -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); } @@ -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 keyed by bridge + // address and rely on them expiring once that 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,7 @@ static void appendSurfaceMember( objectExpandos_; std::unordered_map objectExpandoOwnerCounts_; std::atomic objectExpandosGeneration_{1}; + std::atomic runtimeTornDown_{false}; std::unordered_map> propertyGetterCache_; std::atomic propertyGetterCacheGeneration_{1}; From 58098375c3e89243c3747eb89eef37316b0bf082 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 17 Oct 2026 23:25:09 -0700 Subject: [PATCH 03/14] fix(android): re-attach NativeScript after a JavaScript reload install() returned early on a process-wide flag, so after a reload the new runtime had none of NativeScript's globals while isInstalled() reported true. The old com.tns.Runtime also stayed subscribed to GC notifications and released handles into the destroyed jsi::Runtime. - isInstalled()/install() are per host jsi::Runtime. - invalidate() detaches: releases every handle while the runtime is still alive, unregisters it, and resets main-runtime state so the next attach binds to the new JS thread. Metadata is built once per process. - GC notifications for a detached runtime are no-ops at every layer. - A Java callback into JavaScript that was reloaded away logs a warning and returns a default instead of throwing on the calling thread. --- NativeScript/ffi/jni/jsi/jni/LRUCache.h | 10 +++ .../jni/jsi/objectmanager/ObjectManager.cpp | 4 + NativeScript/runtime/android/jsi/EngineHost.h | 7 +- NativeScript/runtime/android/jsi/Runtime.cpp | 52 ++++++++++-- NativeScript/runtime/android/jsi/Runtime.h | 12 +++ .../runtime/android/jsi/com_tns_Runtime.cpp | 9 +- .../src/main/cpp/NativeScriptRnInstall.cpp | 37 ++++++--- .../NativeScriptNativeApiModule.java | 70 ++++++++++++---- .../src/main/java/com/tns/Runtime.java | 83 +++++++++++++++++++ 9 files changed, 242 insertions(+), 42 deletions(-) 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/runtime/android/jsi/EngineHost.h b/NativeScript/runtime/android/jsi/EngineHost.h index 31dffa0be..dc0b1af28 100644 --- a/NativeScript/runtime/android/jsi/EngineHost.h +++ b/NativeScript/runtime/android/jsi/EngineHost.h @@ -56,9 +56,10 @@ class EngineHost { 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. + // 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(); 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/packages/react-native/android/src/main/cpp/NativeScriptRnInstall.cpp b/packages/react-native/android/src/main/cpp/NativeScriptRnInstall.cpp index fcf76578f..bceb86e01 100644 --- a/packages/react-native/android/src/main/cpp/NativeScriptRnInstall.cpp +++ b/packages/react-native/android/src/main/cpp/NativeScriptRnInstall.cpp @@ -18,15 +18,6 @@ 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; - -} // namespace - extern "C" JNIEXPORT void JNICALL Java_com_tns_Runtime_attachNativeScript( JNIEnv* env, jobject obj, jint runtimeId, jstring metadataPath, jlong hostRuntimePtr, jboolean verboseLoggingEnabled, jint maxLogcatObjectSize, jboolean forceLog, @@ -58,8 +49,21 @@ extern "C" JNIEXPORT void JNICALL Java_com_tns_Runtime_attachNativeScript( runtime->GetEngineHost()->ExecuteScript(source, "internal/ts_helpers.js"); } } + } 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); + } +} - g_attached = true; +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) { @@ -71,9 +75,18 @@ extern "C" JNIEXPORT void JNICALL Java_com_tns_Runtime_attachNativeScript( } } +// 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 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..b249af1de 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,6 +50,8 @@ public class NativeScriptNativeApiModule extends NativeScriptNativeApiSpec { private final ReactApplicationContext reactContext; + private Runtime attachedRuntime; + public NativeScriptNativeApiModule(ReactApplicationContext reactContext) { super(reactContext); this.reactContext = reactContext; @@ -54,13 +62,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,20 +92,30 @@ 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) { @@ -104,7 +128,19 @@ public boolean installWorkletRuntime(com.facebook.react.bridge.ReadableMap runti @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 +174,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 +184,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/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..d9bfc6001 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,9 @@ 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); + private native void runModule(int runtimeId, String filePath) throws NativeScriptException; private native Object runScript(int runtimeId, String filePath) throws NativeScriptException; @@ -142,6 +145,9 @@ public static void passSuppressedExceptionToJs(Throwable ex, String methodName) private boolean initialized; + // Read by the GC listener's thread. + private volatile boolean detached; + 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 +231,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); @@ -520,6 +529,67 @@ private void attach(long hostRuntimePtr, String metadataPath, String bootstrapSc 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; + + 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); + } + + 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 +836,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 +1301,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; From 16c448a65f39a0ea50346e43b5854a49c0277d00 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 17 Oct 2026 23:25:18 -0700 Subject: [PATCH 04/14] fix(ffi/objc): stop defining short enum member names as globals Every key of an enum object became a global, including the short member names (Center, Fill, CloudKit's Zone). Libraries that feature-detect a global by name were misled: Angular's zoneless scheduler checks typeof Zone and crashed at bootstrap after init({ globals: true }). Only full C constant names (NSTextAlignmentCenter, NSOrderedAscending, kCLAuthorizationStatusRestricted) are defined as globals now, in both the JS installGlobals() and the shared global installer used by the Worklets UI runtime. Short names stay on their enum. --- .../ffi/objc/shared/bridge/Install.mm | 20 +++++- packages/react-native/src/index.ts | 22 ++++++- .../test/enum-member-globals.test.js | 62 +++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 packages/react-native/test/enum-member-globals.test.js 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/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index b15d01120..c48e9c3f3 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -906,6 +906,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 +968,7 @@ export function installGlobals(): boolean { continue; } for (const memberName of Object.keys(enumValue)) { - if (/^-?\d+$/.test(memberName)) { + if (!isEnumMemberGlobalName(name, memberName)) { continue; } defineLazyNativeGlobal( 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"); From ae72cd909bb49109494418bf7e606a18b5aa62cb Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 17 Oct 2026 23:25:33 -0700 Subject: [PATCH 05/14] fix(react-native): import without the native module; init() on Android - The TurboModule is looked up with TurboModuleRegistry.get, so importing the package in Expo Go or a build made before it was added no longer raises an uncatchable invariant. isAvailable() reports whether the module is present; init() and the other functions that need it throw an error saying to rebuild. - init() no longer installs into a Worklets runtime on Android, where there is none, so it returns true instead of throwing a message about missing react-native-worklets setup. runOnUI()/installWorklets() on Android say they are iOS only. - README: the Android example uses NativeScript.init(), and the threads section states that Android views cannot be touched from JavaScript. --- packages/react-native/README.md | 14 ++++- .../react-native/src/NativeScriptNativeApi.ts | 5 +- packages/react-native/src/index.d.ts | 7 +++ packages/react-native/src/index.ts | 60 +++++++++++++++---- 4 files changed, 71 insertions(+), 15 deletions(-) diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 4a38a7a1d..e099d96ba 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -522,6 +522,10 @@ npm run test-rn-turbomodule Expo Go cannot load this package because it contains custom native code. Use an Expo development build, EAS Build, or `npx expo run:ios`. +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. + 1. Install the package: ```sh @@ -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; @@ -775,6 +779,12 @@ 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. +That includes callbacks Android runs on the main thread: a `Runnable` posted to +`new Handler(Looper.getMainLooper())` runs its JavaScript on the JS thread. There +is no Worklets UI runtime on Android yet, so `runOnUI()` and the UIKit view +helpers are iOS only, and touching an `android.view.View` from JavaScript throws +`CalledFromWrongThreadException`. Every non-UI Android API is available. + ### What a guest does not install React Native already provides `console`, timers, `queueMicrotask`, 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..1703d9dd3 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; @@ -342,6 +348,7 @@ declare const NativeScript: { init: typeof init; install: typeof install; installGlobals: typeof installGlobals; + isAvailable: typeof isAvailable; isInstalled: typeof isInstalled; defaultMetadataPath: typeof defaultMetadataPath; defineUIKitContainer: typeof defineUIKitContainer; diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index c48e9c3f3..1e9684a99 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; @@ -1003,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). +function supportsWorkletRuntime(): boolean { + return Platform.OS === "ios"; +} + +function assertWorkletRuntimeSupported(): void { + if (!supportsWorkletRuntime()) { + throw new Error( + `NativeScript does not support Worklets on ${Platform.OS}: there is no UI runtime to install into, so runOnUI and UIKit views are iOS only.`, + ); + } +} + +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(); @@ -1014,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; @@ -1023,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; @@ -1312,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", @@ -1331,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", @@ -3254,6 +3289,7 @@ const NativeScript = { init, install, installGlobals, + isAvailable, isInstalled, defaultMetadataPath, defineUIKitContainer, From 3837fdabeaa8b4dd028fe3298654f11e04b4baca Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 17 Oct 2026 23:25:34 -0700 Subject: [PATCH 06/14] fix(react-native): configure the Worklets Babel plugin with strictGlobal Without strictGlobal the Worklets plugin captures every name a worklet does not declare into its closure, read on the JS thread when the module is evaluated, where NativeScript's classes do not exist: a module-level worklet naming UILabel failed at startup with "ReferenceError: Property 'UILabel' doesn't exist". The config plugin and configure CLI now write ['react-native-worklets/plugin', { strictGlobal: true }] and ['babel-preset-expo', { worklets: false }] (so the preset adds no second copy), upgrade bare entries in an existing config, and warn when an entry with options lacks strictGlobal. The Babel setup also runs on Android-only prebuilds. Documented in the README. --- packages/react-native/README.md | 20 +++- .../plugin/withNativeScriptReactNative.js | 99 ++++++++++++------- .../react-native/test/config-plugin.test.js | 79 +++++++++++++++ 3 files changed, 160 insertions(+), 38 deletions(-) diff --git a/packages/react-native/README.md b/packages/react-native/README.md index e099d96ba..182ca8403 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -40,11 +40,20 @@ module.exports = { presets: ["module:@react-native/babel-preset"], plugins: [ "@nativescript/react-native/babel-plugin", - "react-native-worklets/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`. + `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. @@ -512,7 +521,7 @@ npm run test-rn-turbomodule presets: ["module:@react-native/babel-preset"], plugins: [ "@nativescript/react-native/babel-plugin", - "react-native-worklets/plugin", + ["react-native-worklets/plugin", { strictGlobal: true }], ], }; ``` @@ -551,8 +560,11 @@ functions that need the module throw an error saying what to rebuild. 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. + (with `{ strictGlobal: true }`) transforms to `babel.config.js` so `"use js"` + and worklet callbacks work in Expo bundles, 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`. 3. Prebuild and run the iOS development build: diff --git a/packages/react-native/plugin/withNativeScriptReactNative.js b/packages/react-native/plugin/withNativeScriptReactNative.js index e74cc5e22..869c282fb 100644 --- a/packages/react-native/plugin/withNativeScriptReactNative.js +++ b/packages/react-native/plugin/withNativeScriptReactNative.js @@ -11,6 +11,13 @@ 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) { @@ -74,6 +81,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 +110,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 +120,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 +174,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) { diff --git a/packages/react-native/test/config-plugin.test.js b/packages/react-native/test/config-plugin.test.js index 3346c0f61..e240d0475 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: { From 0220fd224c2b6228a92b7b3fd0bf92775ab1c868 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 17 Oct 2026 23:25:38 -0700 Subject: [PATCH 07/14] fix(types): nullable blocks as (() => T) | null; declare NativeObject.extend - The emitter wrote nullable callbacks as `() => void | null`, which TypeScript reads as a function returning void | null, so passing null (e.g. presentViewControllerAnimatedCompletion's completion) was a type error. They are parenthesized now. - extend() works on every native class at runtime but was undeclared. NativeObject.extend types overridden methods from the class, accepts protocol/exposed methods, and takes objc.ExtendOptions. - README: Android declarations are generated by nativescript.gradle by default from the app's classpath; @nativescript/types-android is not needed. --- metadata-generator/src/TSEmitter/TypeSpec.cpp | 3 +- packages/objc-node-api/index.d.ts | 34 +++++++++++++++++++ packages/react-native/README.md | 21 +++++++++--- 3 files changed, 53 insertions(+), 5 deletions(-) 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 182ca8403..39fb14c88 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -680,14 +680,27 @@ That adds two things to your build: `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: +* **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 ``` - which writes `types/android/` and, with `configure` having run, is already - referenced from `tsconfig.json`. + 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"] + } + ``` + + 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 From 4dfc13d5ca3495b60b90498859cd15ff99f95281 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 18 Oct 2026 08:41:54 -0700 Subject: [PATCH 08/14] fix(ffi/objc): key thread_local bridge caches by a per-bridge id The property-getter, round-trip and expando lookup caches identified a bridge by its address. After a JS reload a new bridge can be allocated where a destroyed one lived and match its entries: the getter cache then returned a raw pointer into the freed bridge (EXC_BAD_ACCESS in preparedObjCInvocationIsInit on the next launch), and cached misses could hide values that exist. --- .../ffi/objc/shared/bridge/ObjCBridge.mm | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm index 0385c8e00..6b5727d03 100644 --- a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm +++ b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm @@ -730,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; @@ -744,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; @@ -799,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) { @@ -996,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; @@ -1011,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(); @@ -1027,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); } @@ -1071,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; @@ -1084,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; } @@ -1094,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; } @@ -2140,8 +2140,8 @@ void abandonIfRuntimeTornDown(T& 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 keyed by bridge - // address and rely on them expiring once that bridge is gone. + // 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)); @@ -2205,6 +2205,13 @@ static void abandonRuntimeValues(std::vector& values) { 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}; From 4dc6860fc07a7d853a85ebdcbdebf4711e11192f Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 18 Oct 2026 14:14:51 -0700 Subject: [PATCH 09/14] docs(react-native): rewrite README for npm readers Put requirements and installation first and move the monorepo build scripts to a closing section. Document that iOS JS-thread globals are opt-in via init("", { globals: true }), Android release metadata filtering, and the Android steps of configure and the Expo plugin. Fix examples that touched UIKit or undefined names off the UI runtime. --- packages/react-native/README.md | 455 ++++++++++++++++---------------- 1 file changed, 232 insertions(+), 223 deletions(-) diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 39fb14c88..04fbaa706 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -1,25 +1,168 @@ # @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"] + } + } + ] + ] + } +} +``` + +Bare projects using `nativescript-rn configure` can put the same `metadata` +object under a `nativeScriptReactNative` key in `package.json`. + +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,35 +171,34 @@ await NativeScript.runOnUI(() => { }); ``` -Install `react-native-worklets`, add its Babel plugin, and run `pod install` so -the `RNWorklets` pod is linked: +`runOnUI()` only accepts Worklets callbacks and throws when the callback was not +transformed into a Worklets function; 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", { strictGlobal: true }], - ], -}; +```ts +const NSDateClass = NativeScript.getClass("NSDate"); + +// or +NativeScript.init("", { globals: true }); +const now = NSDate.new(); ``` -`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`. +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. -`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. +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 @@ -64,13 +206,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 @@ -78,7 +224,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(() => { @@ -154,11 +300,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 @@ -248,7 +394,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"); @@ -260,7 +408,7 @@ const dataSource = NativeScript.createDelegate( return 1; }, previewControllerPreviewItemAtIndex() { - return NSURL.fileURLWithPath(path); + return NSURL.fileURLWithPath(ctx.props.path); }, }, { owner: ctx }, @@ -406,12 +554,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; @@ -449,7 +598,6 @@ if ( const CameraController = NativeScript.getClass< typeof VNDocumentCameraViewController >("VNDocumentCameraViewController"); - const controller = CameraController?.new(); } ``` @@ -470,175 +618,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", { strictGlobal: true }], - ], - }; - ``` - -## 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`. - -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. - -1. Install the package: - - ```sh - npx expo install @nativescript/react-native react-native-worklets - ``` +More example definitions ship in the package's `examples/` folder, for example +`@nativescript/react-native/examples/UIKitSwitch`. - 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` - (with `{ strictGlobal: true }`) transforms to `babel.config.js` so `"use js"` - and worklet callbacks work in Expo bundles, 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`. - -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 @@ -666,8 +650,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" @@ -675,11 +659,20 @@ 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. + + 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. A class your code only reaches + dynamically (a name built at runtime, for example) can therefore resolve in + debug and be missing in release. In a release build, `whitelist.mdg` adds to + that set rather than replacing it, so list such classes there. + `-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 @@ -817,3 +810,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 +``` From 932467ddc489fe97ee803890bd243972e5dbfff5 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 18 Oct 2026 14:16:01 -0700 Subject: [PATCH 10/14] fix(react-native): keep metadata.android when writing nativescript.react-native.json The config plugin and nativescript-rn configure replaced the whole reactNative.metadata block, so every prebuild or configure run deleted a hand-written metadata.android.include. That list is what keeps classes, including autolinked module packages, in filtered Android release metadata. Merge into the existing metadata instead, accept metadata.android.include in the plugin options, and write the file on Android-only prebuilds too. Document the include list in the README. --- packages/react-native/README.md | 35 ++++++++++++---- .../plugin/withNativeScriptReactNative.js | 42 ++++++++++++------- .../react-native/test/config-plugin.test.js | 40 ++++++++++++++++++ 3 files changed, 93 insertions(+), 24 deletions(-) diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 04fbaa706..07c6be083 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -136,8 +136,11 @@ Objective-C-visible pods or extra system frameworks: } ``` -Bare projects using `nativescript-rn configure` can put the same `metadata` -object under a `nativeScriptReactNative` key in `package.json`. +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 @@ -667,12 +670,28 @@ That adds two things to your build: 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. A class your code only reaches - dynamically (a name built at runtime, for example) can therefore resolve in - debug and be missing in release. In a release build, `whitelist.mdg` adds to - that set rather than replacing it, so list such classes there. - `-PnsFilterMetadata=false` turns the filter off and ships the whole - classpath. + 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 diff --git a/packages/react-native/plugin/withNativeScriptReactNative.js b/packages/react-native/plugin/withNativeScriptReactNative.js index 869c282fb..8f3d78349 100644 --- a/packages/react-native/plugin/withNativeScriptReactNative.js +++ b/packages/react-native/plugin/withNativeScriptReactNative.js @@ -24,16 +24,23 @@ 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 = {}) { @@ -67,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`; @@ -189,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/test/config-plugin.test.js b/packages/react-native/test/config-plugin.test.js index e240d0475..b63c00626 100644 --- a/packages/react-native/test/config-plugin.test.js +++ b/packages/react-native/test/config-plugin.test.js @@ -177,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'); From c0db43d5a4a07ed96908ea86cf4c4504d81944b7 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 18 Oct 2026 14:29:02 -0700 Subject: [PATCH 11/14] fix(react-native): say Android typings are generated by default in configure nativescript.gradle generates them unless -PnsGenerateTypings=false, so the next-steps hint no longer calls them on demand or passes =true. --- packages/react-native/cli/configure.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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)) { From cb89fd9c333c1c80bb009e4aa56ec9fa589ff923 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 22 Oct 2026 14:33:10 -0700 Subject: [PATCH 12/14] feat(android): run JavaScript on the main thread under a host runtime A guest's JS thread is not the main thread, so no JavaScript could touch an Android view. The main thread now borrows the jsi::Runtime while the JS thread waits, as React Native's executeSynchronouslyOnSameThread does: - Java->JS callbacks made on the main thread (a view's listeners) run there instead of being posted to the JS thread. - __nativeScriptRunOnMainThread(fn) runs fn on the main thread from the JS thread, which lends the runtime until it returns. - A JS thread parked inside a Java->JS call releases the EngineHost lock for the borrower and takes it back after. - Callbacks delivered as plain looper messages drain microtasks, which no React Native task would otherwise do. Also installs __nativeScriptAndroidViewHandle, __nativeScriptAndroidContext and __nativeScriptIsMainThread for the JS API. --- .../runtime/android/jsi/EngineHost.cpp | 617 +++++++++--------- NativeScript/runtime/android/jsi/EngineHost.h | 260 ++++---- .../src/main/cpp/NativeScriptRnInstall.cpp | 216 ++++++ .../NativeScriptNativeApiModule.java | 4 +- .../reactnative/NativeScriptViewRegistry.java | 74 +++ .../src/main/java/com/tns/MainThreadLoan.java | 202 ++++++ .../src/main/java/com/tns/Runtime.java | 78 +++ 7 files changed, 1030 insertions(+), 421 deletions(-) create mode 100644 packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptViewRegistry.java create mode 100644 platforms/android/test-app/runtime/src/main/java/com/tns/MainThreadLoan.java 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 dc0b1af28..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,99 +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. 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); + // 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 }; @@ -151,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 }; @@ -230,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/packages/react-native/android/src/main/cpp/NativeScriptRnInstall.cpp b/packages/react-native/android/src/main/cpp/NativeScriptRnInstall.cpp index bceb86e01..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" @@ -18,6 +21,155 @@ using namespace tns; +namespace { + +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 + extern "C" JNIEXPORT void JNICALL Java_com_tns_Runtime_attachNativeScript( JNIEnv* env, jobject obj, jint runtimeId, jstring metadataPath, jlong hostRuntimePtr, jboolean verboseLoggingEnabled, jint maxLogcatObjectSize, jboolean forceLog, @@ -49,6 +201,8 @@ extern "C" JNIEXPORT void JNICALL Java_com_tns_Runtime_attachNativeScript( runtime->GetEngineHost()->ExecuteScript(source, "internal/ts_helpers.js"); } } + + InstallGuestFunctions(env, obj, runtime); } catch (NativeScriptException& e) { e.ReThrowToJava(nullptr); } catch (std::exception& e) { @@ -96,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 b249af1de..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 @@ -55,6 +55,7 @@ public class NativeScriptNativeApiModule extends NativeScriptNativeApiSpec { public NativeScriptNativeApiModule(ReactApplicationContext reactContext) { super(reactContext); this.reactContext = reactContext; + NativeScriptViewRegistry.setReactContext(reactContext); } @Override @@ -122,7 +123,8 @@ public boolean installWorkletRuntime(com.facebook.react.bridge.ReadableMap runti // 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; } 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/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 d9bfc6001..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 @@ -56,6 +56,15 @@ private native void attachNativeScript(int runtimeId, String metadataPath, long // 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; @@ -148,6 +157,9 @@ public static void passSuppressedExceptionToJs(Throwable ex, String methodName) // 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."; @@ -524,6 +536,8 @@ 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; @@ -541,6 +555,10 @@ public void detach() { } detached = true; + if (mainThreadLoan != null) { + mainThreadLoan.close(); + } + GcListener.unsubscribe(this); runtimeCache.remove(runtimeId); if (currentRuntime.get() == this) { @@ -560,6 +578,47 @@ public void detach() { 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) { @@ -1453,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]; @@ -1474,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; } From 72713f5ca12443a3147ca4eaef85d61b46e8a422 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 22 Oct 2026 14:33:12 -0700 Subject: [PATCH 13/14] feat(react-native/android): NativeScriptUIView shows an android.view.View The Fabric component was registered on Android with no view manager. It now hosts the view its nativeViewHandle names, sized to the host, moving it from any previous host. --- .../NativeScriptNativeApiPackage.java | 9 ++ .../reactnative/NativeScriptUIHostView.java | 75 ++++++++++++++++ .../NativeScriptUIViewManager.java | 87 +++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptUIHostView.java create mode 100644 packages/react-native/android/src/main/java/org/nativescript/reactnative/NativeScriptUIViewManager.java 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) { + } +} From 1050be2a1ee1e65de8d796e454da1b9abae8326c Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 22 Oct 2026 14:33:13 -0700 Subject: [PATCH 14/14] feat(react-native): runOnUI, nativeViewHandle and androidContext on Android runOnUI runs its callback on the Android main thread instead of throwing. nativeViewHandle(view) names a view for NativeScriptUIView on both platforms, androidContext() is the Context to build a view with, and isMainThread() answers on Android. The README's Android section describes the threading model and how to show a view. --- packages/react-native/README.md | 64 +++++-- packages/react-native/src/index.d.ts | 19 ++ packages/react-native/src/index.ts | 55 +++++- .../test/android-main-thread-api.test.js | 162 ++++++++++++++++++ 4 files changed, 283 insertions(+), 17 deletions(-) create mode 100644 packages/react-native/test/android-main-thread-api.test.js diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 07c6be083..3dd46c8a3 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -174,8 +174,9 @@ await NativeScript.runOnUI(() => { }); ``` -`runOnUI()` only accepts Worklets callbacks and throws when the callback was not -transformed into a Worklets function; running React Native's JS-thread runtime +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. @@ -810,17 +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. - -That includes callbacks Android runs on the main thread: a `Runnable` posted to -`new Handler(Looper.getMainLooper())` runs its JavaScript on the JS thread. There -is no Worklets UI runtime on Android yet, so `runOnUI()` and the UIKit view -helpers are iOS only, and touching an `android.view.View` from JavaScript throws -`CalledFromWrongThreadException`. Every non-UI Android API is available. +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 diff --git a/packages/react-native/src/index.d.ts b/packages/react-native/src/index.d.ts index 1703d9dd3..818bacaa7 100644 --- a/packages/react-native/src/index.d.ts +++ b/packages/react-native/src/index.d.ts @@ -286,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 @@ -305,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; @@ -345,6 +362,7 @@ export function defineUIViewController< ): UIKitViewComponent; declare const NativeScript: { + androidContext: typeof androidContext; init: typeof init; install: typeof install; installGlobals: typeof installGlobals; @@ -368,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 1e9684a99..466d98a72 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -1017,7 +1017,7 @@ function nativeModule(): NativeScriptNativeApiSpec { } // Android has no Worklets runtime to install into (see installWorkletRuntime in -// NativeScriptNativeApiModule.java). +// NativeScriptNativeApiModule.java): runOnUI runs on its main thread instead. function supportsWorkletRuntime(): boolean { return Platform.OS === "ios"; } @@ -1025,7 +1025,7 @@ function supportsWorkletRuntime(): boolean { function assertWorkletRuntimeSupported(): void { if (!supportsWorkletRuntime()) { throw new Error( - `NativeScript does not support Worklets on ${Platform.OS}: there is no UI runtime to install into, so runOnUI and UIKit views are iOS only.`, + `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.`, ); } } @@ -1401,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( @@ -1410,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, @@ -1567,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( @@ -3286,6 +3331,7 @@ export function defineUIViewController< } const NativeScript = { + androidContext, init, install, installGlobals, @@ -3308,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); +});