From bc3d0950e2c4d753603316e6284c7aaa43a091a5 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Mon, 7 Sep 2026 15:21:11 -0700 Subject: [PATCH 1/4] fix(metadata-generator): find libclang outside the CI machine's Xcode layout The generator links @rpath/libclang.dylib and only carried the rpath of the toolchain that linked it, so a published package failed with "Library not loaded" on any machine whose Xcode is installed under another name. The binary now also carries the stock Xcode.app and Command Line Tools rpaths, and the build-step driver hands dyld the active toolchain through DYLD_FALLBACK_LIBRARY_PATH for every remaining layout. --- metadata-generator/CMakeLists.txt | 15 ++++- .../build-step-metadata-generator.py | 55 ++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/metadata-generator/CMakeLists.txt b/metadata-generator/CMakeLists.txt index 12f4a04aa..586d2f019 100644 --- a/metadata-generator/CMakeLists.txt +++ b/metadata-generator/CMakeLists.txt @@ -100,10 +100,21 @@ target_link_libraries( clang ) -# Set runtime path for libclang +# dyld tries these in order for @rpath/libclang.dylib. The toolchain that +# linked the binary comes first; the stock Xcode and Command Line Tools +# locations keep the binary loadable on machines whose Xcode lives elsewhere +# (a renamed or beta Xcode bundle). build-step-metadata-generator.py covers the +# remaining cases through DYLD_FALLBACK_LIBRARY_PATH. +set(LIBCLANG_RPATHS + "${LIBCLANG_DIR}" + "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib" + "/Library/Developer/CommandLineTools/usr/lib" +) +list(REMOVE_DUPLICATES LIBCLANG_RPATHS) + set_target_properties(${NAME} PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE - INSTALL_RPATH "${LIBCLANG_DIR}" + INSTALL_RPATH "${LIBCLANG_RPATHS}" ) install(TARGETS ${NAME} diff --git a/metadata-generator/build-step-metadata-generator.py b/metadata-generator/build-step-metadata-generator.py index 444b37d9d..32c4a4860 100755 --- a/metadata-generator/build-step-metadata-generator.py +++ b/metadata-generator/build-step-metadata-generator.py @@ -177,6 +177,58 @@ def is_nativescript_source_root(search_path): signature_bindings_cpp_path = default_signature_bindings_path +def libclang_search_dirs(): + """Toolchain lib dirs holding libclang.dylib, active toolchain first. + + The generator links @rpath/libclang.dylib with an rpath pointing at the + toolchain of the machine that built it. That path need not exist on the + machine running the build (Xcode installed under another name, a beta, or + Command Line Tools only), so the active toolchain has to be offered to dyld + as a fallback. + """ + candidates = [] + for env_name in ("TOOLCHAIN_DIR", "DT_TOOLCHAIN_DIR"): + toolchain_dir = env_or_empty(env_name) + if toolchain_dir: + candidates.append(os.path.join(toolchain_dir, "usr", "lib")) + + try: + clang_path = subprocess.check_output( + ["xcrun", "--find", "clang"], + stderr=subprocess.DEVNULL, + universal_newlines=True + ).strip() + except (OSError, subprocess.CalledProcessError): + clang_path = "" + if clang_path: + candidates.append(os.path.join(os.path.dirname(os.path.dirname(clang_path)), "lib")) + + developer_dir = env_or_empty("DEVELOPER_DIR") + if developer_dir: + candidates.append(os.path.join(developer_dir, "Toolchains", "XcodeDefault.xctoolchain", "usr", "lib")) + + candidates.append("/Library/Developer/CommandLineTools/usr/lib") + + result = [] + for candidate in candidates: + if candidate in result: + continue + if os.path.isfile(os.path.join(candidate, "libclang.dylib")): + result.append(candidate) + return result + + +def generator_environment(): + child_env = os.environ.copy() + search_dirs = libclang_search_dirs() + if search_dirs: + existing = child_env.get("DYLD_FALLBACK_LIBRARY_PATH") + if existing: + search_dirs.append(existing) + child_env["DYLD_FALLBACK_LIBRARY_PATH"] = ":".join(search_dirs) + return child_env + + def save_stream_to_file(filename, stream): f = open(filename, "w") f.write(stream) @@ -246,7 +298,8 @@ def generate_metadata(arch): generator_call, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - universal_newlines=True + universal_newlines=True, + env=generator_environment() ) sys.stdout.flush() output_stream_content, error_stream_content = child_process.communicate() From a62d04c89ee28b7a8054344e02f31695899c38e2 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Mon, 7 Sep 2026 15:21:11 -0700 Subject: [PATCH 2/4] fix(ios): make the NAPI runtime packages buildable and launchable from an app Two gaps between the packaged runtime and what an app build needs: - The xcframework ships an arm64-only simulator slice, but the CLI builds simulator targets with ONLY_ACTIVE_ARCH=NO, so the x86_64 link failed. The template xcconfig now keeps x86_64 out of simulator builds. - ios-hermes links a dynamic hermes.framework that the package never shipped, so the app died in dyld. The hermes variant now ships internal/hermes.xcframework and the pack step adds it to the template's Embed Frameworks phase. --- .../ios/internal/nativescript-build.xcconfig | 7 +- scripts/build_npm_ios.sh | 12 ++++ scripts/embed_engine_xcframework.py | 70 +++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100755 scripts/embed_engine_xcframework.py diff --git a/platforms/apple/templates/ios/internal/nativescript-build.xcconfig b/platforms/apple/templates/ios/internal/nativescript-build.xcconfig index 8a3fc311e..be9809316 100644 --- a/platforms/apple/templates/ios/internal/nativescript-build.xcconfig +++ b/platforms/apple/templates/ios/internal/nativescript-build.xcconfig @@ -18,9 +18,10 @@ LDPLUSPLUS = $SRCROOT/internal/nsld.sh // * It will be generated on each build, so you can find it after running "ns build ios" in "YOUR_APP/platforms/ios". // NS_DEBUG_METADATA_PATH = $(SRCROOT)/debug-metadata -// Xcode 12 -EXCLUDED_ARCHS_x86_64 = arm64 arm64e -EXCLUDED_ARCHS[sdk=iphonesimulator*] = i386 armv6 armv7 armv7s armv8 $(EXCLUDED_ARCHS_$(NATIVE_ARCH_64_BIT)) +// NativeScript.xcframework ships an arm64-only simulator slice, so x86_64 must +// stay out of simulator builds even though ARCHS_STANDARD lists it and the CLI +// builds simulator targets with ONLY_ACTIVE_ARCH=NO. +EXCLUDED_ARCHS[sdk=iphonesimulator*] = i386 x86_64 armv6 armv7 armv7s armv8 EXCLUDED_ARCHS[sdk=iphoneos*] = i386 armv6 armv7 armv7s armv8 x86_64 EXCLUDED_ARCHS[sdk=macosx*] = i386 armv6 armv7 armv7s armv8 VALIDATE_WORKSPACE = YES diff --git a/scripts/build_npm_ios.sh b/scripts/build_npm_ios.sh index f7bd1bf6b..d03b6311b 100755 --- a/scripts/build_npm_ios.sh +++ b/scripts/build_npm_ios.sh @@ -38,6 +38,18 @@ cp -R "./platforms/apple/templates/ios/." "$STAGING_DIR/framework" cp -R "dist/NativeScript.xcframework" "$STAGING_DIR/framework/internal" cp -R "dist/TKLiveSync.xcframework" "$STAGING_DIR/framework/internal" +# Hermes is linked as its own dynamic framework (@rpath/hermes.framework), so +# the package has to carry it and the app template has to embed it. +if [ "$IOS_VARIANT" = "ios-hermes" ]; then + if [ ! -d "Frameworks/hermes.xcframework" ]; then + echo "Frameworks/hermes.xcframework is missing; run scripts/download_hermes.sh first." >&2 + exit 1 + fi + cp -R "Frameworks/hermes.xcframework" "$STAGING_DIR/framework/internal" + python3 "$SCRIPT_DIR/embed_engine_xcframework.py" \ + "$STAGING_DIR/framework/__PROJECT_NAME__.xcodeproj/project.pbxproj" "hermes.xcframework" +fi + mkdir -p "$STAGING_DIR/framework/internal/metadata-generator-x86_64" cp -R "metadata-generator/dist/x86_64/." "$STAGING_DIR/framework/internal/metadata-generator-x86_64" diff --git a/scripts/embed_engine_xcframework.py b/scripts/embed_engine_xcframework.py new file mode 100755 index 000000000..132bd9e1e --- /dev/null +++ b/scripts/embed_engine_xcframework.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Add an internal xcframework to the app template's Embed Frameworks phase. + +Usage: embed_engine_xcframework.py + +The template project embeds NativeScript.xcframework and TKLiveSync.xcframework +by explicit entries. An engine that ships as its own dynamic framework (Hermes) +must be embedded the same way, or dyld cannot find it at app launch. The new +entries are cloned from the TKLiveSync ones so they land in the same group, +build phase and settings; ids are derived from the framework name so re-running +is idempotent. +""" + +import hashlib +import re +import sys + +TEMPLATE_NAME = "TKLiveSync.xcframework" + + +def pbx_id(seed): + return hashlib.md5(seed.encode("utf-8")).hexdigest()[:24].upper() + + +def main(pbxproj_path, framework_name): + with open(pbxproj_path) as f: + lines = f.read().split("\n") + + if any(framework_name in line for line in lines): + print("{} already referenced in {}".format(framework_name, pbxproj_path)) + return 0 + + template_lines = [line for line in lines if TEMPLATE_NAME in line] + template_ids = sorted(set(re.findall(r"\b([0-9A-F]{24})\b", "\n".join(template_lines)))) + if len(template_ids) != 2: + print("expected the file reference and build file ids for {}, found {}".format( + TEMPLATE_NAME, template_ids), file=sys.stderr) + return 1 + + # The build-file entry references the file reference (`fileRef = `), so + # the id that appears as a fileRef target is the file reference. + joined = "\n".join(template_lines) + file_ref_id = next(i for i in template_ids if re.search(r"fileRef = " + i, joined)) + build_file_id = next(i for i in template_ids if i != file_ref_id) + replacements = { + file_ref_id: pbx_id(framework_name + ":fileRef"), + build_file_id: pbx_id(framework_name + ":buildFile"), + TEMPLATE_NAME: framework_name, + } + + output = [] + for line in lines: + output.append(line) + if TEMPLATE_NAME in line: + clone = line + for old, new in replacements.items(): + clone = clone.replace(old, new) + output.append(clone) + + with open(pbxproj_path, "w") as f: + f.write("\n".join(output)) + print("embedded {} in {}".format(framework_name, pbxproj_path)) + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print(__doc__, file=sys.stderr) + sys.exit(2) + sys.exit(main(sys.argv[1], sys.argv[2])) From de41833a52132296c83d4532914ce909d05203d8 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Mon, 7 Sep 2026 15:21:11 -0700 Subject: [PATCH 3/4] feat(android): load ES modules and run a microtask checkpoint after Java calls Vite emits the app as .mjs chunks with a lazily imported Activity chunk. The Android Node-API runtime could not load them: the loader rejected the extension, the Java resolver only probed .js, and promise reactions never ran after the entry module because Hermes, QuickJS and PrimJS only drain their job queue on request. The Activity was then instantiated before its JS extension existed. - Extract the Apple loader's ES-module-to-CommonJS rewrite into a unit shared by both platforms and use it for every Android engine. It binds generated code to captured aliases rather than the wrapper parameters (bundler output declares its own top-level module/exports), rewrites import.meta, and scans instead of running std::regex over whole files (the vendor chunk took seconds and ANR'd the app). - Resolve .mjs and .cjs in Module.java, in the Apple runtime's order. - Drain the job queue when the outermost call from Java into JS returns, after worker callbacks, and after the worker entry script; the Hermes drain now converts a throwing job into a pending exception instead of unwinding through JNI. - Report errors that arrive before core installs its handler to logcat instead of dropping them. - Forward the onlyArm64 property so a local runtime build can target one ABI. --- NativeScript/CMakeLists.txt | 1 + .../napi/exceptions/NativeScriptException.cpp | 24 + NativeScript/napi/hermes/jsr.cpp | 17 +- NativeScript/runtime/android/napi/Runtime.cpp | 36 ++ NativeScript/runtime/android/napi/Runtime.h | 9 + .../runtime/android/napi/com_tns_Runtime.cpp | 26 + .../napi/modules/module/ModuleInternal.cpp | 52 +- .../napi/modules/module/ModuleInternal.h | 2 + .../android/napi/workers/WorkerWrapper.cpp | 10 + .../apple/modules/module/ModuleInternal.cpp | 390 +------------ .../runtime/modules/esm/ESModuleSupport.cpp | 536 ++++++++++++++++++ .../runtime/modules/esm/ESModuleSupport.h | 75 +++ platforms/android/build.gradle | 7 + .../android/test-app/runtime/CMakeLists.txt | 2 + .../runtime/src/main/java/com/tns/Module.java | 56 +- 15 files changed, 823 insertions(+), 420 deletions(-) create mode 100644 NativeScript/runtime/modules/esm/ESModuleSupport.cpp create mode 100644 NativeScript/runtime/modules/esm/ESModuleSupport.h diff --git a/NativeScript/CMakeLists.txt b/NativeScript/CMakeLists.txt index b8e494301..766b2c936 100644 --- a/NativeScript/CMakeLists.txt +++ b/NativeScript/CMakeLists.txt @@ -342,6 +342,7 @@ if(ENABLE_JS_RUNTIME) runtime/modules/url/ada/ada.cpp runtime/modules/url/URL.cpp runtime/modules/url/URLSearchParams.cpp + runtime/modules/esm/ESModuleSupport.cpp ) if(TARGET_ENGINE_V8) diff --git a/NativeScript/ffi/jni/napi/exceptions/NativeScriptException.cpp b/NativeScript/ffi/jni/napi/exceptions/NativeScriptException.cpp index ec11c1b4e..fe8bfa51c 100644 --- a/NativeScript/ffi/jni/napi/exceptions/NativeScriptException.cpp +++ b/NativeScript/ffi/jni/napi/exceptions/NativeScriptException.cpp @@ -187,7 +187,31 @@ void NativeScriptException::CallJsFuncWithErr(napi_env env, napi_value errObj, b if (napi_util::is_of_type(env, handler, napi_function)) { napi_value result; NAPI_GUARD(napi_call_function(env, global, handler, 1, &errObj, &result)) {} + return; + } + + // Nothing in JS can observe this error yet: it happened before the app + // installed its handler, typically while the entry module was loading, so + // logcat is the only place left to report it. + std::string report; + for (const char* propertyName : {"stack", "message", "stackTrace"}) { + napi_value value = nullptr; + if (napi_get_named_property(env, errObj, propertyName, &value) != napi_ok || + !napi_util::is_of_type(env, value, napi_string)) { + continue; + } + std::string text = ArgConverter::ConvertToString(env, value); + if (text.empty() || report.find(text) != std::string::npos) { + continue; + } + if (!report.empty()) { + report += "\n"; + } + report += text; } + __android_log_print(ANDROID_LOG_ERROR, "JS", + "Uncaught error before a JS error handler was installed:\n%s", + report.c_str()); } napi_value NativeScriptException::WrapJavaToJsException(napi_env env) { diff --git a/NativeScript/napi/hermes/jsr.cpp b/NativeScript/napi/hermes/jsr.cpp index 0da333809..f49726f1b 100644 --- a/NativeScript/napi/hermes/jsr.cpp +++ b/NativeScript/napi/hermes/jsr.cpp @@ -176,7 +176,8 @@ napi_status js_create_napi_env(napi_env* env, jsr_ns_runtime runtime) { // // Apple used to take a different route through a NativeScript-local // jsi::Runtime::createNodeApiEnv hook. That hook no longer exists upstream, - // and both platforms now build against the same headers, so there is one path. + // and both platforms now build against the same headers, so there is one + // path. auto hermesInterface = facebook::jsi::castInterface( runtime->hermes->rt); @@ -310,7 +311,19 @@ napi_status js_execute_pending_jobs(napi_env env) { if (jsr == nullptr) { return napi_invalid_arg; } - jsr->rt->drainMicrotasks(); + // drainMicrotasks() reports a failing job by throwing a C++ jsi::JSError + // (see jsr_drain_microtasks). Callers sit behind JNI and Looper callbacks, + // where an unwinding C++ exception aborts the process, so it is converted + // into a pending exception they already know how to report. + try { + jsr->rt->drainMicrotasks(); + } catch (const facebook::jsi::JSError& e) { + napi_throw_error(env, nullptr, e.getMessage().c_str()); + return napi_pending_exception; + } catch (const facebook::jsi::JSIException& e) { + napi_throw_error(env, nullptr, e.what()); + return napi_pending_exception; + } return napi_ok; #else bool result; diff --git a/NativeScript/runtime/android/napi/Runtime.cpp b/NativeScript/runtime/android/napi/Runtime.cpp index 49fc0455f..fc4d5bf47 100644 --- a/NativeScript/runtime/android/napi/Runtime.cpp +++ b/NativeScript/runtime/android/napi/Runtime.cpp @@ -594,6 +594,42 @@ void Runtime::RunModule(const char *moduleName) { void Runtime::RunWorker(const std::string &filePath) { m_module.LoadWorker(env, filePath); + js_execute_pending_jobs(env); +} + +void Runtime::EnterJsCall() { + m_jsCallDepth++; +} + +void Runtime::LeaveJsCall() { + m_jsCallDepth--; +} + +// Engines with an explicit job queue (Hermes, QuickJS, PrimJS) run promise +// reactions only when asked; V8 and JavaScriptCore do it themselves as soon as +// the JS stack empties. This reproduces that moment: the queue is drained after +// the outermost call from Java into JS and never inside a nested one, so jobs +// cannot interleave with a JS frame that is still on the stack. +void Runtime::RunMicrotaskCheckpoint() { + if (m_jsCallDepth > 1) { + return; + } + + napi_status status = js_execute_pending_jobs(env); + bool pendingException = false; + napi_is_exception_pending(env, &pendingException); + if (status == napi_ok && !pendingException) { + return; + } + + napi_value error = nullptr; + if (pendingException) { + napi_get_and_clear_last_exception(env, &error); + } + if (error != nullptr) { + throw NativeScriptException(env, error, "Error running microtasks"); + } + throw NativeScriptException("Error running microtasks"); } void Runtime::DisposeWorkerRuntime(Runtime *runtime) { diff --git a/NativeScript/runtime/android/napi/Runtime.h b/NativeScript/runtime/android/napi/Runtime.h index e3c78be88..6ee398dfd 100644 --- a/NativeScript/runtime/android/napi/Runtime.h +++ b/NativeScript/runtime/android/napi/Runtime.h @@ -90,6 +90,14 @@ namespace tns { std::string ReadFileText(const std::string &filePath); + // Java -> JS transitions are counted so the microtask checkpoint runs + // only when the outermost one returns; see RunMicrotaskCheckpoint. + void EnterJsCall(); + + void LeaveJsCall(); + + void RunMicrotaskCheckpoint(); + bool NotifyGC(JNIEnv *jEnv, jobject obj, jintArray object_ids); bool TryCallGC(); @@ -194,6 +202,7 @@ namespace tns { ArrayBufferHelper m_arrayBufferHelper; bool m_isMainThread; + int m_jsCallDepth = 0; ModuleInternal m_module; diff --git a/NativeScript/runtime/android/napi/com_tns_Runtime.cpp b/NativeScript/runtime/android/napi/com_tns_Runtime.cpp index 452104588..94b3dc2e2 100644 --- a/NativeScript/runtime/android/napi/com_tns_Runtime.cpp +++ b/NativeScript/runtime/android/napi/com_tns_Runtime.cpp @@ -129,6 +129,22 @@ Runtime* TryGetRuntime(int runtimeId) { return runtime; } +// Brackets a call from Java into JS so the runtime knows when the outermost +// one returns; see Runtime::RunMicrotaskCheckpoint. +class JsCallScope { +public: + explicit JsCallScope(Runtime* runtime) : m_runtime(runtime) { + m_runtime->EnterJsCall(); + } + + ~JsCallScope() { + m_runtime->LeaveJsCall(); + } + +private: + Runtime* m_runtime; +}; + extern "C" JNIEXPORT void Java_com_tns_Runtime_runModule(JNIEnv* _env, jobject obj, jint runtimeId, jstring scriptFile) { auto runtime = TryGetRuntime(runtimeId); if (runtime == nullptr) { @@ -136,9 +152,11 @@ extern "C" JNIEXPORT void Java_com_tns_Runtime_runModule(JNIEnv* _env, jobject o } NapiScope scope(runtime->GetNapiEnv()); + JsCallScope call(runtime); try { runtime->RunModule(_env, obj, scriptFile); + runtime->RunMicrotaskCheckpoint(); } catch (NativeScriptException& e) { e.ReThrowToJava(runtime->GetNapiEnv()); } catch (std::exception e) { @@ -160,8 +178,10 @@ extern "C" JNIEXPORT jobject Java_com_tns_Runtime_runScript(JNIEnv* _env, jobjec napi_env napiEnv = runtime->GetNapiEnv(); NapiScope scope(napiEnv); + JsCallScope call(runtime); try { result = runtime->RunScript(_env, obj, scriptFile); + runtime->RunMicrotaskCheckpoint(); } catch (NativeScriptException& e) { e.ReThrowToJava(napiEnv); } catch (std::exception e) { @@ -183,8 +203,10 @@ extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _en if (runtime == nullptr) return result; NapiScope scope(runtime->GetNapiEnv()); + JsCallScope call(runtime); try { result = runtime->CallJSMethodNative(_env, obj, javaObjectID, claz, methodName, retType, isConstructor, packagedArgs); + runtime->RunMicrotaskCheckpoint(); } catch (NativeScriptException& e) { e.ReThrowToJava( runtime->GetNapiEnv()); } catch (std::exception e) { @@ -206,9 +228,11 @@ extern "C" JNIEXPORT void Java_com_tns_Runtime_createJSInstanceNative(JNIEnv* _e if (runtime == nullptr) return; NapiScope scope(runtime->GetNapiEnv()); + JsCallScope call(runtime); try { runtime->CreateJSInstanceNative(_env, obj, javaObject, javaObjectID, className); + runtime->RunMicrotaskCheckpoint(); } catch (NativeScriptException& e) { e.ReThrowToJava( runtime->GetNapiEnv()); } catch (std::exception e) { @@ -286,9 +310,11 @@ extern "C" JNIEXPORT void Java_com_tns_Runtime_passExceptionToJsNative(JNIEnv* j if (runtime == nullptr) return; NapiScope scope(runtime->GetNapiEnv()); + JsCallScope call(runtime); try { runtime->PassExceptionToJsNative(jEnv, obj, exception, message, fullStackTrace, jsStackTrace, isDiscarded, isPendingError); + runtime->RunMicrotaskCheckpoint(); } catch (NativeScriptException& e) { e.ReThrowToJava(runtime->GetNapiEnv()); } catch (std::exception e) { diff --git a/NativeScript/runtime/android/napi/modules/module/ModuleInternal.cpp b/NativeScript/runtime/android/napi/modules/module/ModuleInternal.cpp index 44549a386..6b72372ae 100644 --- a/NativeScript/runtime/android/napi/modules/module/ModuleInternal.cpp +++ b/NativeScript/runtime/android/napi/modules/module/ModuleInternal.cpp @@ -15,6 +15,7 @@ #include #include #include "GlobalHelpers.h" +#include "ESModuleSupport.h" #include @@ -34,6 +35,10 @@ void ThrowFallbackRequireError(napi_env env, const char* message) { napi_throw_error(env, nullptr, message); } +bool IsJavaScriptModulePath(const std::string& path) { + return Util::EndsWith(path, ".js") || Util::EndsWith(path, ".mjs") || Util::EndsWith(path, ".cjs"); +} + void ReThrowRequireError(napi_env env, NativeScriptException& exception) { try { exception.ReThrowToNapi(env); @@ -353,7 +358,7 @@ napi_value ModuleInternal::LoadImpl(napi_env env, const std::string& moduleName, auto it2 = m_loadedModules.find(path); if (it2 == m_loadedModules.end()) { - if (Util::EndsWith(path, ".js") || Util::EndsWith(path, ".so")) { + if (IsJavaScriptModulePath(path) || Util::EndsWith(path, ".so")) { isData = false; result = LoadModule(env, path, cachePathKey); } else if (Util::EndsWith(path, ".json")) { @@ -424,18 +429,26 @@ napi_value ModuleInternal::LoadModule(napi_env env, const std::string& modulePat napi_value moduleFunc; - if (Util::EndsWith(modulePath, ".js")) { + if (IsJavaScriptModulePath(modulePath)) { DEBUG_WRITE("%s", modulePath.c_str()); - // Fast path: if the build compiled this module to engine bytecode, run it - // directly. This peeks the file header only — the source is never read or - // wrapped for a bytecode module. Bytecode is the compiled form of the - // *wrapped* module content, so it yields the same wrapper function. - status = js_run_bytecode_file(env, EnsureFileProtocol(modulePath).c_str(), &moduleFunc); - if (status == napi_cannot_run_js) { - // Not bytecode — compile and run the wrapped source as usual. - napi_value script = LoadScript(env, modulePath, fullRequiredModulePath); + if (nativescript::esm::IsESModulePath(modulePath)) { + // ES module sources are rewritten to CommonJS at load time, so the + // bytecode compiler never produced a precompiled form to try first. + napi_util::define_property(env, exportsObj, "__esModule", napi_util::get_true(env)); + napi_value script = WrapESModuleContent(env, modulePath); status = js_execute_script(env, script, EnsureFileProtocol(modulePath).c_str(), &moduleFunc); + } else { + // Fast path: if the build compiled this module to engine bytecode, run it + // directly. This peeks the file header only — the source is never read or + // wrapped for a bytecode module. Bytecode is the compiled form of the + // *wrapped* module content, so it yields the same wrapper function. + status = js_run_bytecode_file(env, EnsureFileProtocol(modulePath).c_str(), &moduleFunc); + if (status == napi_cannot_run_js) { + // Not bytecode — compile and run the wrapped source as usual. + napi_value script = LoadScript(env, modulePath, fullRequiredModulePath); + status = js_execute_script(env, script, EnsureFileProtocol(modulePath).c_str(), &moduleFunc); + } } if (status != napi_ok) { bool pendingException; @@ -566,12 +579,27 @@ napi_value ModuleInternal::LoadData(napi_env env, const std::string& path) { } napi_value ModuleInternal::WrapModuleContent(napi_env env, const std::string& path) { + std::string content = nativescript::esm::RewriteCommonJSDynamicImportsForFallbackEngines( + nativescript::esm::StripShebang(Runtime::GetRuntime(m_env)->ReadFileText(path))); + return WrapWithModuleFunction(env, content, false /* isESModule */); +} - std::string content = Runtime::GetRuntime(m_env)->ReadFileText(path); +napi_value ModuleInternal::WrapESModuleContent(napi_env env, const std::string& path) { + std::string content = nativescript::esm::TransformESModuleForFallbackEngines( + nativescript::esm::StripShebang(Runtime::GetRuntime(m_env)->ReadFileText(path))); + return WrapWithModuleFunction(env, content, true /* isESModule */); +} - // TODO: Use statically allocated buffer for better performance +napi_value ModuleInternal::WrapWithModuleFunction(napi_env env, const std::string& content, bool isESModule) { + // The shims share the prologue's line so source line numbers are unchanged. + // MODULE_PROLOGUE itself stays byte-identical to the one the bytecode + // compiler wraps with; only source-evaluated modules get the shims. std::string result(MODULE_PROLOGUE); result.reserve(content.length() + 1024); + result += NS_ESM_FALLBACK_DYNAMIC_IMPORT_SHIM; + if (isESModule) { + result += NS_ESM_FALLBACK_MODULE_SHIM; + } result += content; result += MODULE_EPILOGUE; diff --git a/NativeScript/runtime/android/napi/modules/module/ModuleInternal.h b/NativeScript/runtime/android/napi/modules/module/ModuleInternal.h index fc048ed56..4f5db4337 100644 --- a/NativeScript/runtime/android/napi/modules/module/ModuleInternal.h +++ b/NativeScript/runtime/android/napi/modules/module/ModuleInternal.h @@ -63,6 +63,8 @@ class ModuleInternal { napi_value RequireCallbackImpl(napi_env env, napi_callback_info info); napi_value WrapModuleContent(napi_env env, const std::string& path); + napi_value WrapESModuleContent(napi_env env, const std::string& path); + napi_value WrapWithModuleFunction(napi_env env, const std::string& content, bool isESModule); napi_value LoadImpl(napi_env env, const std::string& moduleName, const std::string& baseDir, bool& isData); diff --git a/NativeScript/runtime/android/napi/workers/WorkerWrapper.cpp b/NativeScript/runtime/android/napi/workers/WorkerWrapper.cpp index 21fa72e02..bdcf323c7 100644 --- a/NativeScript/runtime/android/napi/workers/WorkerWrapper.cpp +++ b/NativeScript/runtime/android/napi/workers/WorkerWrapper.cpp @@ -1,3 +1,4 @@ +#include "jsr_common.h" #include "WorkerWrapper.h" #include @@ -180,6 +181,9 @@ void WorkerWrapper::DrainPendingTasks() { napi_value args[1] = {event}; napi_value result; status = napi_call_function(env, globalObject, callback, 1, args, &result); + if (status == napi_ok) { + status = js_execute_pending_jobs(env); + } if (status == napi_pending_exception && !isTerminating_) { napi_value error; NAPI_GUARD(napi_get_and_clear_last_exception(env, &error)) {} @@ -229,6 +233,9 @@ void WorkerWrapper::FireMessageOnParentWorkerObject(int workerId, napi_value args[1] = {event}; napi_value result; status = napi_call_function(env, worker, callback, 1, args, &result); + if (status == napi_ok) { + status = js_execute_pending_jobs(env); + } if (status == napi_pending_exception) { napi_value error; NAPI_GUARD(napi_get_and_clear_last_exception(env, &error)) {} @@ -313,6 +320,9 @@ void WorkerWrapper::FireErrorOnParentWorkerObject(int workerId, const std::strin napi_value args[1] = {errEvent}; napi_value result; status = napi_call_function(env, worker, callback, 1, args, &result); + if (status == napi_ok) { + status = js_execute_pending_jobs(env); + } if (status == napi_pending_exception) { napi_value exception; diff --git a/NativeScript/runtime/apple/modules/module/ModuleInternal.cpp b/NativeScript/runtime/apple/modules/module/ModuleInternal.cpp index 97179926b..c04bb55fa 100644 --- a/NativeScript/runtime/apple/modules/module/ModuleInternal.cpp +++ b/NativeScript/runtime/apple/modules/module/ModuleInternal.cpp @@ -22,6 +22,7 @@ #include "runtime/apple/Util.h" #include "runtime/apple/modules/node/Node.h" #include "runtime/apple/modules/web/Web.h" +#include "runtime/modules/esm/ESModuleSupport.h" #ifdef TARGET_ENGINE_V8 // vendor/v8 is on the include path for V8 builds (see NativeScript/CMakeLists). @@ -38,384 +39,11 @@ extern std::unordered_map napiModuleRegistry; } using namespace nativescript; +using namespace nativescript::esm; using namespace std; namespace { -// Cache for package.json \"type\" field lookups. -// -// Deliberately leaked rather than held in a namespace-scope object. The only -// caller of DeInit() is ~Runtime, which runs from the destructor of the global -// `runtime_` unique_ptr in NativeScript.mm -- i.e. during static destruction at -// exit(). Destruction order between two translation units in the same image is -// unspecified, and here this map was being destroyed first: DeInit() then -// called clear() on a dead unordered_map and freed its already-freed nodes, -// aborting every run with -// "___BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED_WAS_NOT_ALLOCATED" after -// the suite had finished. A function-local pointer that is never deleted has no -// destruction order to get wrong. -std::unordered_map& modulePackageTypeCache() { - static auto* cache = new std::unordered_map(); - return *cache; -} - -// Strip shebang line from source code (e.g., #!/usr/bin/env node) -std::string StripShebang(const std::string& source) { - if (source.size() >= 2 && source[0] == '#' && source[1] == '!') { - size_t lineEnd = source.find('\n'); - if (lineEnd != std::string::npos) { - return source.substr(lineEnd + 1); - } - return ""; // Entire file is just a shebang - } - return source; -} - -#if defined(TARGET_ENGINE_HERMES) || defined(TARGET_ENGINE_JSC) -std::string RewriteCommonJSDynamicImportsForFallbackEngines( - const std::string& source) { - static const std::regex kDynamicImportPattern( - R"((^|[^A-Za-z0-9_$\.])import\s*\()", - std::regex::ECMAScript | std::regex::multiline); - return std::regex_replace(source, kDynamicImportPattern, - "$1__dynamicImport("); -} - -std::string TrimFallbackESMToken(const std::string& value) { - const auto begin = value.find_first_not_of(" \t\r\n"); - if (begin == std::string::npos) { - return ""; - } - const auto end = value.find_last_not_of(" \t\r\n"); - return value.substr(begin, end - begin + 1); -} - -std::vector SplitFallbackESMList(const std::string& value) { - std::vector parts; - std::stringstream stream(value); - std::string part; - while (std::getline(stream, part, ',')) { - part = TrimFallbackESMToken(part); - if (!part.empty()) { - parts.push_back(part); - } - } - return parts; -} - -std::string EscapeFallbackESMSpecifier(const std::string& specifier) { - std::string escaped; - escaped.reserve(specifier.size()); - for (char c : specifier) { - switch (c) { - case '\\': - escaped += "\\\\"; - break; - case '\'': - escaped += "\\'"; - break; - case '\n': - escaped += "\\n"; - break; - case '\r': - escaped += "\\r"; - break; - default: - escaped += c; - break; - } - } - return escaped; -} - -std::string FallbackESMRequireExpression(const std::string& specifier) { - return "require('" + EscapeFallbackESMSpecifier(specifier) + "')"; -} - -std::string RewriteFallbackESMImportBindings(const std::string& bindings) { - std::string result; - for (const auto& part : SplitFallbackESMList(bindings)) { - static const std::regex kAliasPattern( - R"(^([A-Za-z_$][A-Za-z0-9_$]*)\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)$)"); - std::smatch alias; - if (std::regex_match(part, alias, kAliasPattern)) { - result += (result.empty() ? "" : ", "); - result += alias[1].str() + ": " + alias[2].str(); - } else { - result += (result.empty() ? "" : ", "); - result += part; - } - } - return result; -} - -std::string FallbackESMExportAssignments(const std::string& exports, - const std::string& sourceObject = "") { - std::string result; - for (const auto& part : SplitFallbackESMList(exports)) { - static const std::regex kAliasPattern( - R"(^([A-Za-z_$][A-Za-z0-9_$]*)\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)$)"); - std::smatch alias; - std::string local = part; - std::string exported = part; - if (std::regex_match(part, alias, kAliasPattern)) { - local = alias[1].str(); - exported = alias[2].str(); - } - - if (!result.empty()) { - result += "\n"; - } - result += "exports." + exported + " = "; - result += sourceObject.empty() ? local : sourceObject + "." + local; - result += ";"; - } - return result; -} - -template -std::string RegexReplaceWithFallbackESMCallback(const std::string& source, - const std::regex& pattern, - Callback callback) { - std::string result; - std::sregex_iterator it(source.begin(), source.end(), pattern); - std::sregex_iterator end; - size_t last = 0; - for (; it != end; ++it) { - const std::smatch& match = *it; - result.append(source, last, match.position() - last); - result += callback(match); - last = match.position() + match.length(); - } - result.append(source, last, std::string::npos); - return result; -} - -std::string TransformESModuleForFallbackEngines(const std::string& source) { - std::string result = source; - int tempIndex = 0; - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*import[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*,[ \t]*\*[ \t]+as[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", - std::regex::ECMAScript | std::regex::multiline), - [&](const std::smatch& match) { - std::string module = "__esm_import_" + std::to_string(tempIndex++); - return "const " + module + " = " + - FallbackESMRequireExpression(match[3].str()) + ";\nconst " + - match[1].str() + " = " + module + ".default;\nconst " + - match[2].str() + " = " + module + ";"; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*import[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*,[ \t]*\{([^}]*)\}[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", - std::regex::ECMAScript | std::regex::multiline), - [&](const std::smatch& match) { - std::string module = "__esm_import_" + std::to_string(tempIndex++); - return "const " + module + " = " + - FallbackESMRequireExpression(match[3].str()) + ";\nconst " + - match[1].str() + " = " + module + ".default;\nconst {" + - RewriteFallbackESMImportBindings(match[2].str()) + "} = " + - module + ";"; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*import[ \t]+\{([^}]*)\}[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return "const {" + RewriteFallbackESMImportBindings(match[1].str()) + - "} = " + FallbackESMRequireExpression(match[2].str()) + ";"; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*import[ \t]+\*[ \t]+as[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return "const " + match[1].str() + " = " + - FallbackESMRequireExpression(match[2].str()) + ";"; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*import[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return "const " + match[1].str() + " = " + - FallbackESMRequireExpression(match[2].str()) + ".default;"; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex(R"(^[ \t]*import[ \t]+['"]([^'"]+)['"][ \t]*;?)", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return FallbackESMRequireExpression(match[1].str()) + ";"; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*export[ \t]+\*[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return "Object.assign(exports, " + - FallbackESMRequireExpression(match[1].str()) + ");"; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*export[ \t]+\{([^}]*)\}[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", - std::regex::ECMAScript | std::regex::multiline), - [&](const std::smatch& match) { - std::string module = "__esm_export_" + std::to_string(tempIndex++); - return "const " + module + " = " + - FallbackESMRequireExpression(match[2].str()) + ";\n" + - FallbackESMExportAssignments(match[1].str(), module); - }); - - result = std::regex_replace( - result, - std::regex(R"(^[ \t]*export[ \t]+default[ \t]+function[ \t]*)", - std::regex::ECMAScript | std::regex::multiline), - "exports.default = function "); - - result = std::regex_replace( - result, - std::regex(R"(^[ \t]*export[ \t]+default[ \t]+class[ \t]*)", - std::regex::ECMAScript | std::regex::multiline), - "exports.default = class "); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*export[ \t]+function[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*\()", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return "exports." + match[1].str() + " = function " + match[1].str() + - "("; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*export[ \t]+class[ \t]+([A-Za-z_$][A-Za-z0-9_$]*))", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return "exports." + match[1].str() + " = class " + match[1].str(); - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*export[ \t]+(const|let|var)[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*=[ \t]*([^;\r\n]*);?)", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return match[1].str() + " " + match[2].str() + " = " + - match[3].str() + ";\nexports." + match[2].str() + " = " + - match[2].str() + ";"; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex(R"(^[ \t]*export[ \t]*\{([^}]*)\}[ \t]*;)", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return FallbackESMExportAssignments(match[1].str()); - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex(R"(^[ \t]*export[ \t]+default[ \t]+([^;\r\n]*);?)", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return "exports.default = " + match[1].str() + ";"; - }); - - return RewriteCommonJSDynamicImportsForFallbackEngines(result); -} -#endif - -// Check if path has .cjs extension (explicitly CommonJS) -bool IsCJSModule(const std::string& path) { - return path.size() >= 4 && path.compare(path.size() - 4, 4, ".cjs") == 0; -} - -// Find nearest package.json by walking up from directory -std::string FindNearestPackageJson(const std::filesystem::path& startDir) { - std::filesystem::path current = startDir; - - while (!current.empty() && current != current.root_path()) { - std::filesystem::path packagePath = current / "package.json"; - std::error_code ec; - if (std::filesystem::exists(packagePath, ec) && !ec) { - return packagePath.string(); - } - current = current.parent_path(); - } - - return ""; -} - -// Check if package.json has "type": "module" -bool IsPackageTypeModule(const std::string& packageJsonPath) { - auto& cache = modulePackageTypeCache(); - auto cacheIt = cache.find(packageJsonPath); - if (cacheIt != cache.end()) { - return cacheIt->second; - } - - bool isModule = false; - - std::ifstream file(packageJsonPath); - if (file.is_open()) { - std::string content((std::istreambuf_iterator(file)), - std::istreambuf_iterator()); - file.close(); - - // Simple JSON parsing for "type": "module" - size_t typePos = content.find("\"type\""); - if (typePos != std::string::npos) { - size_t colonPos = content.find(':', typePos + 6); - if (colonPos != std::string::npos) { - size_t valueStart = content.find('"', colonPos + 1); - if (valueStart != std::string::npos) { - size_t valueEnd = content.find('"', valueStart + 1); - if (valueEnd != std::string::npos) { - std::string typeValue = - content.substr(valueStart + 1, valueEnd - valueStart - 1); - isModule = (typeValue == "module"); - } - } - } - } - } - - cache[packageJsonPath] = isModule; - return isModule; -} - -// Determine if a .js file should be treated as ESM based on nearest -// package.json -bool ShouldTreatJsAsESModule(const std::string& path) { - std::filesystem::path filePath(path); - std::string packageJson = FindNearestPackageJson(filePath.parent_path()); - - if (!packageJson.empty()) { - return IsPackageTypeModule(packageJson); - } - - return false; // Default to CommonJS -} - bool PathExistsWithExactCase(const std::filesystem::path& path) { std::error_code ec; if (!std::filesystem::exists(path, ec) || ec) { @@ -832,8 +460,7 @@ void ModuleInternal::DeInit() { v8impl::g_moduleRegistry.clear(); #endif - // Clear the package.json type cache - modulePackageTypeCache().clear(); + ClearPackageTypeCache(); if (m_env != nullptr) { napi_delete_reference(m_env, this->m_requireFunction); @@ -1821,6 +1448,7 @@ napi_value ModuleInternal::LoadESModule(napi_env env, const std::string& path) { std::string wrapped; wrapped.reserve(transformed.length() + 1024); wrapped += MODULE_PROLOGUE; + wrapped += NS_ESM_FALLBACK_MODULE_SHIM; wrapped += transformed; wrapped += MODULE_EPILOGUE; @@ -2101,15 +1729,7 @@ ModuleInternal::ModulePathKind ModuleInternal::GetModulePathKind( #if defined(TARGET_ENGINE_HERMES) || defined(TARGET_ENGINE_JSC) const char* ModuleInternal::MODULE_PROLOGUE = "(function(module, exports, require, __filename, __dirname){ " - "const __dynamicImport = (specifier) => Promise.resolve().then(() => { " - "const __loaded = require(specifier); " - "if (__loaded !== null && (typeof __loaded === 'object' || typeof __loaded " - "=== 'function')) { " - "if (__loaded.__esModule) { return __loaded; } " - "return Object.assign({ default: __loaded }, __loaded); " - "} " - "return { default: __loaded }; " - "}); "; + NS_ESM_FALLBACK_DYNAMIC_IMPORT_SHIM; #else const char* ModuleInternal::MODULE_PROLOGUE = "(function(module, exports, require, __filename, __dirname){ "; diff --git a/NativeScript/runtime/modules/esm/ESModuleSupport.cpp b/NativeScript/runtime/modules/esm/ESModuleSupport.cpp new file mode 100644 index 000000000..6cc92eaab --- /dev/null +++ b/NativeScript/runtime/modules/esm/ESModuleSupport.cpp @@ -0,0 +1,536 @@ +#include "ESModuleSupport.h" + +#include +#include +#include +#include +#include +#include + +namespace nativescript::esm { + +namespace { + +// Deliberately leaked rather than held in a namespace-scope object: the Apple +// runtime clears this cache from ~Runtime, which runs during static destruction +// at exit(). Destruction order between translation units is unspecified, and a +// namespace-scope map was being destroyed before that call, so clear() ran on +// a dead object and aborted the process in libmalloc. A function-local pointer +// that is never deleted has no destruction order to get wrong. +std::unordered_map& PackageTypeCache() { + static auto* cache = new std::unordered_map(); + return *cache; +} + +std::string TrimFallbackESMToken(const std::string& value) { + const auto begin = value.find_first_not_of(" \t\r\n"); + if (begin == std::string::npos) { + return ""; + } + const auto end = value.find_last_not_of(" \t\r\n"); + return value.substr(begin, end - begin + 1); +} + +std::vector SplitFallbackESMList(const std::string& value) { + std::vector parts; + std::stringstream stream(value); + std::string part; + while (std::getline(stream, part, ',')) { + part = TrimFallbackESMToken(part); + if (!part.empty()) { + parts.push_back(part); + } + } + return parts; +} + +std::string EscapeFallbackESMSpecifier(const std::string& specifier) { + std::string escaped; + escaped.reserve(specifier.size()); + for (char c : specifier) { + switch (c) { + case '\\': + escaped += "\\\\"; + break; + case '\'': + escaped += "\\'"; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + default: + escaped += c; + break; + } + } + return escaped; +} + +std::string FallbackESMRequireExpression(const std::string& specifier) { + return "__esm_require('" + EscapeFallbackESMSpecifier(specifier) + "')"; +} + +std::string RewriteFallbackESMImportBindings(const std::string& bindings) { + std::string result; + for (const auto& part : SplitFallbackESMList(bindings)) { + static const std::regex kAliasPattern( + R"(^([A-Za-z_$][A-Za-z0-9_$]*)\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)$)"); + std::smatch alias; + if (std::regex_match(part, alias, kAliasPattern)) { + result += (result.empty() ? "" : ", "); + result += alias[1].str() + ": " + alias[2].str(); + } else { + result += (result.empty() ? "" : ", "); + result += part; + } + } + return result; +} + +std::string FallbackESMExportAssignments(const std::string& exports, + const std::string& sourceObject = "") { + std::string result; + for (const auto& part : SplitFallbackESMList(exports)) { + static const std::regex kAliasPattern( + R"(^([A-Za-z_$][A-Za-z0-9_$]*)\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)$)"); + std::smatch alias; + std::string local = part; + std::string exported = part; + if (std::regex_match(part, alias, kAliasPattern)) { + local = alias[1].str(); + exported = alias[2].str(); + } + + if (!result.empty()) { + result += "\n"; + } + result += "__esm_exports." + exported + " = "; + result += sourceObject.empty() ? local : sourceObject + "." + local; + result += ";"; + } + return result; +} + +template +std::string RegexReplaceWithFallbackESMCallback(const std::string& source, + const std::regex& pattern, + Callback callback) { + std::string result; + std::sregex_iterator it(source.begin(), source.end(), pattern); + std::sregex_iterator end; + size_t last = 0; + for (; it != end; ++it) { + const std::smatch& match = *it; + result.append(source, last, match.position() - last); + result += callback(match); + last = match.position() + match.length(); + } + result.append(source, last, std::string::npos); + return result; +} + +} // namespace + +std::string StripShebang(const std::string& source) { + if (source.size() >= 2 && source[0] == '#' && source[1] == '!') { + size_t lineEnd = source.find('\n'); + if (lineEnd != std::string::npos) { + return source.substr(lineEnd + 1); + } + return ""; + } + return source; +} + +namespace { + +bool IsIdentifierChar(char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '_' || c == '$'; +} + +// Replaces every `import` keyword that `matchLength` accepts. Given the index +// just past the keyword, `matchLength` returns how many further characters the +// replacement swallows, or npos to leave the keyword alone. Plain scanning +// instead of std::regex: bundles run to megabytes and a regex pass over the +// whole buffer takes seconds on device. +template +std::string RewriteImportKeyword(const std::string& source, + const char* replacement, + MatchLength matchLength) { + static const std::string kKeyword = "import"; + std::string result; + result.reserve(source.size() + 256); + size_t last = 0; + size_t pos = source.find(kKeyword); + while (pos != std::string::npos) { + size_t end = pos + kKeyword.size(); + bool startsToken = pos == 0 || !IsIdentifierChar(source[pos - 1]); + size_t extra = + startsToken ? matchLength(source, pos, end) : std::string::npos; + if (extra != std::string::npos) { + result.append(source, last, pos - last); + result += replacement; + end += extra; + last = end; + } + pos = source.find(kKeyword, end); + } + result.append(source, last, std::string::npos); + return result; +} + +} // namespace + +std::string RewriteCommonJSDynamicImportsForFallbackEngines( + const std::string& source) { + return RewriteImportKeyword( + source, "__dynamicImport", + [](const std::string& text, size_t start, size_t end) -> size_t { + // `foo.import(` is a method call, not the operator. + if (start > 0 && text[start - 1] == '.') { + return std::string::npos; + } + size_t cursor = end; + while (cursor < text.size() && + (text[cursor] == ' ' || text[cursor] == '\t' || + text[cursor] == '\r' || text[cursor] == '\n')) { + cursor++; + } + bool isCall = cursor < text.size() && text[cursor] == '('; + return isCall ? 0 : std::string::npos; + }); +} + +std::string RewriteImportMeta(const std::string& source) { + return RewriteImportKeyword( + source, "__importMeta", + [](const std::string& text, size_t, size_t end) -> size_t { + static const std::string kMeta = ".meta"; + if (text.compare(end, kMeta.size(), kMeta) != 0) { + return std::string::npos; + } + size_t after = end + kMeta.size(); + bool endsToken = after >= text.size() || !IsIdentifierChar(text[after]); + return endsToken ? kMeta.size() : std::string::npos; + }); +} + +namespace { + +// True when `line` begins (after indentation) with an import or export +// keyword, i.e. it may open a static module statement. +bool StartsModuleStatement(const std::string& line) { + size_t i = 0; + while (i < line.size() && (line[i] == ' ' || line[i] == '\t')) { + i++; + } + for (const char* keyword : {"import", "export"}) { + size_t length = std::strlen(keyword); + if (line.compare(i, length, keyword) == 0 && + (i + length >= line.size() || !IsIdentifierChar(line[i + length]))) { + return true; + } + } + return false; +} + +int BraceBalance(const std::string& text) { + int balance = 0; + for (char c : text) { + if (c == '{') { + balance++; + } else if (c == '}') { + balance--; + } + } + return balance; +} + +std::string TransformModuleStatement(const std::string& statement, + int& tempIndex); + +} // namespace + +std::string TransformESModuleForFallbackEngines(const std::string& source) { + // Only the handful of lines that open an import/export statement go through + // the regex chain; everything else is copied through untouched. A statement + // whose binding list is split across lines is gathered until its braces + // balance so the multi-line regex forms still see the whole statement. + std::string result; + result.reserve(source.size() + 1024); + int tempIndex = 0; + size_t pos = 0; + while (pos < source.size()) { + size_t lineEnd = source.find('\n', pos); + size_t next = lineEnd == std::string::npos ? source.size() : lineEnd + 1; + std::string line = source.substr(pos, next - pos); + if (!StartsModuleStatement(line)) { + result += line; + pos = next; + continue; + } + std::string statement = line; + int balance = BraceBalance(statement); + while (balance > 0 && next < source.size()) { + size_t continuationEnd = source.find('\n', next); + size_t continuationNext = continuationEnd == std::string::npos + ? source.size() + : continuationEnd + 1; + std::string continuation = source.substr(next, continuationNext - next); + statement += continuation; + balance += BraceBalance(continuation); + next = continuationNext; + } + result += TransformModuleStatement(statement, tempIndex); + pos = next; + } + + result = RewriteImportMeta(result); + return RewriteCommonJSDynamicImportsForFallbackEngines(result); +} + +namespace { + +std::string TransformModuleStatement(const std::string& statement, + int& tempIndex) { + std::string result = statement; + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*import[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*,[ \t]*\*[ \t]+as[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", + std::regex::ECMAScript | std::regex::multiline), + [&](const std::smatch& match) { + std::string module = "__esm_import_" + std::to_string(tempIndex++); + return "const " + module + " = " + + FallbackESMRequireExpression(match[3].str()) + ";\nconst " + + match[1].str() + " = " + module + ".default;\nconst " + + match[2].str() + " = " + module + ";"; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*import[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*,[ \t]*\{([^}]*)\}[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", + std::regex::ECMAScript | std::regex::multiline), + [&](const std::smatch& match) { + std::string module = "__esm_import_" + std::to_string(tempIndex++); + return "const " + module + " = " + + FallbackESMRequireExpression(match[3].str()) + ";\nconst " + + match[1].str() + " = " + module + ".default;\nconst {" + + RewriteFallbackESMImportBindings(match[2].str()) + + "} = " + module + ";"; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*import[ \t]+\{([^}]*)\}[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return "const {" + RewriteFallbackESMImportBindings(match[1].str()) + + "} = " + FallbackESMRequireExpression(match[2].str()) + ";"; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*import[ \t]+\*[ \t]+as[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return "const " + match[1].str() + " = " + + FallbackESMRequireExpression(match[2].str()) + ";"; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*import[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return "const " + match[1].str() + " = " + + FallbackESMRequireExpression(match[2].str()) + ".default;"; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex(R"(^[ \t]*import[ \t]+['"]([^'"]+)['"][ \t]*;?)", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return FallbackESMRequireExpression(match[1].str()) + ";"; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*export[ \t]+\*[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return "Object.assign(__esm_exports, " + + FallbackESMRequireExpression(match[1].str()) + ");"; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*export[ \t]+\{([^}]*)\}[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", + std::regex::ECMAScript | std::regex::multiline), + [&](const std::smatch& match) { + std::string module = "__esm_export_" + std::to_string(tempIndex++); + return "const " + module + " = " + + FallbackESMRequireExpression(match[2].str()) + ";\n" + + FallbackESMExportAssignments(match[1].str(), module); + }); + + result = std::regex_replace( + result, + std::regex(R"(^[ \t]*export[ \t]+default[ \t]+function[ \t]*)", + std::regex::ECMAScript | std::regex::multiline), + "__esm_exports.default = function "); + + result = std::regex_replace( + result, + std::regex(R"(^[ \t]*export[ \t]+default[ \t]+class[ \t]*)", + std::regex::ECMAScript | std::regex::multiline), + "__esm_exports.default = class "); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*export[ \t]+function[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*\()", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return "__esm_exports." + match[1].str() + " = function " + + match[1].str() + "("; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex(R"(^[ \t]*export[ \t]+class[ \t]+([A-Za-z_$][A-Za-z0-9_$]*))", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return "__esm_exports." + match[1].str() + " = class " + match[1].str(); + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*export[ \t]+(const|let|var)[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*=[ \t]*([^;\r\n]*);?)", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return match[1].str() + " " + match[2].str() + " = " + match[3].str() + + ";\n__esm_exports." + match[2].str() + " = " + match[2].str() + + ";"; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex(R"(^[ \t]*export[ \t]*\{([^}]*)\}[ \t]*;)", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return FallbackESMExportAssignments(match[1].str()); + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex(R"(^[ \t]*export[ \t]+default[ \t]+([^;\r\n]*);?)", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return "__esm_exports.default = " + match[1].str() + ";"; + }); + + return result; +} + +} // namespace + +bool IsCJSModule(const std::string& path) { + return path.size() >= 4 && path.compare(path.size() - 4, 4, ".cjs") == 0; +} + +std::string FindNearestPackageJson(const std::filesystem::path& startDir) { + std::filesystem::path current = startDir; + + while (!current.empty() && current != current.root_path()) { + std::filesystem::path packagePath = current / "package.json"; + std::error_code ec; + if (std::filesystem::exists(packagePath, ec) && !ec) { + return packagePath.string(); + } + current = current.parent_path(); + } + + return ""; +} + +bool IsPackageTypeModule(const std::string& packageJsonPath) { + auto& cache = PackageTypeCache(); + auto cacheIt = cache.find(packageJsonPath); + if (cacheIt != cache.end()) { + return cacheIt->second; + } + + bool isModule = false; + + std::ifstream file(packageJsonPath); + if (file.is_open()) { + std::string content((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + file.close(); + + // Only the top-level "type" key matters; a full JSON parse is not needed. + size_t typePos = content.find("\"type\""); + if (typePos != std::string::npos) { + size_t colonPos = content.find(':', typePos + 6); + if (colonPos != std::string::npos) { + size_t valueStart = content.find('"', colonPos + 1); + if (valueStart != std::string::npos) { + size_t valueEnd = content.find('"', valueStart + 1); + if (valueEnd != std::string::npos) { + std::string typeValue = + content.substr(valueStart + 1, valueEnd - valueStart - 1); + isModule = (typeValue == "module"); + } + } + } + } + } + + cache[packageJsonPath] = isModule; + return isModule; +} + +bool ShouldTreatJsAsESModule(const std::string& path) { + std::filesystem::path filePath(path); + std::string packageJson = FindNearestPackageJson(filePath.parent_path()); + + if (!packageJson.empty()) { + return IsPackageTypeModule(packageJson); + } + + return false; +} + +bool IsESModulePath(const std::string& path) { + if (path.size() >= 4 && path.compare(path.size() - 4, 4, ".mjs") == 0) { + return true; + } + + if (IsCJSModule(path)) { + return false; + } + + if (path.size() >= 3 && path.compare(path.size() - 3, 3, ".js") == 0) { + return ShouldTreatJsAsESModule(path); + } + + return false; +} + +void ClearPackageTypeCache() { PackageTypeCache().clear(); } + +} // namespace nativescript::esm diff --git a/NativeScript/runtime/modules/esm/ESModuleSupport.h b/NativeScript/runtime/modules/esm/ESModuleSupport.h new file mode 100644 index 000000000..8ee1a95ea --- /dev/null +++ b/NativeScript/runtime/modules/esm/ESModuleSupport.h @@ -0,0 +1,75 @@ +#ifndef NS_RUNTIME_MODULES_ESM_ESMODULESUPPORT_H_ +#define NS_RUNTIME_MODULES_ESM_ESMODULESUPPORT_H_ + +#include +#include + +// Engine-neutral ES module support shared by the Apple and Android runtimes. +// +// Engines without a native module loader (Hermes, JavaScriptCore, PrimJS, and +// every engine on Android, where no host import hooks are wired) evaluate ES +// modules through the CommonJS wrapper: the source is rewritten statement by +// statement into `require`/`exports` form and run as a regular module. The +// rewrite is textual and covers the shapes bundlers emit; it is not a parser. +// +// The rewritten source never touches the wrapper parameters `module`, +// `exports` and `require` directly: bundler output may declare top-level +// variables of the same names inside an ES module (rolldown's CommonJS interop +// emits `var module = { exports: {} }; var exports = module.exports;`), which +// would silently redirect the generated export assignments. It binds instead +// to `__esm_exports` and `__esm_require`, captured by the shims below at +// wrapper entry, plus `__dynamicImport` (replaces `import()`) and +// `__importMeta` (replaces `import.meta`), built from `__filename` and +// `__dirname`. + +// Resolves `import()` through the module-scoped `require`, so relative +// specifiers resolve against the importing module rather than the app root. +#define NS_ESM_FALLBACK_DYNAMIC_IMPORT_SHIM \ + "const __esm_require = require; " \ + "const __dynamicImport = (specifier) => Promise.resolve().then(() => { " \ + "const __loaded = __esm_require(specifier); " \ + "if (__loaded !== null && (typeof __loaded === 'object' || typeof " \ + "__loaded === 'function')) { " \ + "if (__loaded.__esModule) { return __loaded; } " \ + "return Object.assign({ default: __loaded }, __loaded); " \ + "} " \ + "return { default: __loaded }; " \ + "}); " + +#define NS_ESM_FALLBACK_MODULE_SHIM \ + "const __esm_exports = exports; " \ + "const __importMeta = { url: 'file://' + __filename, filename: __filename, " \ + "dirname: __dirname, main: false }; " + +namespace nativescript::esm { + +std::string StripShebang(const std::string& source); + +// `import(` -> `__dynamicImport(` for CommonJS sources evaluated on engines +// whose parser rejects dynamic import or whose host has no import hook. +std::string RewriteCommonJSDynamicImportsForFallbackEngines( + const std::string& source); + +// Full ES module -> CommonJS rewrite: static imports/exports, `import.meta` +// and dynamic imports. The result must be wrapped by the CommonJS prologue +// together with both shims above. +std::string TransformESModuleForFallbackEngines(const std::string& source); + +bool IsCJSModule(const std::string& path); + +std::string FindNearestPackageJson(const std::filesystem::path& startDir); + +// Cached per package.json path; see ClearPackageTypeCache. +bool IsPackageTypeModule(const std::string& packageJsonPath); + +bool ShouldTreatJsAsESModule(const std::string& path); + +// .mjs is always ESM, .cjs never, and .js follows the nearest package.json +// "type" field. +bool IsESModulePath(const std::string& path); + +void ClearPackageTypeCache(); + +} // namespace nativescript::esm + +#endif // NS_RUNTIME_MODULES_ESM_ESMODULESUPPORT_H_ diff --git a/platforms/android/build.gradle b/platforms/android/build.gradle index e71babf3f..7811c6745 100644 --- a/platforms/android/build.gradle +++ b/platforms/android/build.gradle @@ -15,6 +15,7 @@ import groovy.json.JsonBuilder import groovy.json.JsonOutput def onlyX86 = project.hasProperty("onlyX86") +def onlyArm64 = project.hasProperty("onlyArm64") def useCCache = !project.hasProperty("noCCache") def hasNdkVersion = project.hasProperty("ndkVersion") @@ -225,6 +226,9 @@ def getAssembleReleaseBuildArguments = { -> if (onlyX86) { arguments.add("-PonlyX86") } + if (onlyArm64) { + arguments.add("-PonlyArm64") + } if (useCCache) { arguments.add("-PuseCCache") } @@ -522,6 +526,9 @@ def getRunTestsBuildArguments = { taskName -> if (onlyX86) { arguments.add("-PonlyX86") } + if (onlyArm64) { + arguments.add("-PonlyArm64") + } if (useCCache) { arguments.add("-PuseCCache") } diff --git a/platforms/android/test-app/runtime/CMakeLists.txt b/platforms/android/test-app/runtime/CMakeLists.txt index ea26287a2..bbbf33337 100644 --- a/platforms/android/test-app/runtime/CMakeLists.txt +++ b/platforms/android/test-app/runtime/CMakeLists.txt @@ -118,6 +118,7 @@ include_directories( # shared runtime modules ${NS_RUNTIME_MODULES_DIR} ${NS_RUNTIME_MODULES_DIR}/url + ${NS_RUNTIME_MODULES_DIR}/esm # shared Node-API headers ${NS_NAPI_DIR}/common ) @@ -143,6 +144,7 @@ if (NS_BINDING STREQUAL "napi") ${NS_RUNTIME_MODULES_DIR}/url/URLSearchParams.cpp ${NS_RUNTIME_MODULES_DIR}/url/URLPattern.cpp ${NS_RUNTIME_MODULES_DIR}/url/ada/ada.cpp + ${NS_RUNTIME_MODULES_DIR}/esm/ESModuleSupport.cpp ) # modules/url: URL, URLSearchParams, URLPattern (backed by vendored ada) else () diff --git a/platforms/android/test-app/runtime/src/main/java/com/tns/Module.java b/platforms/android/test-app/runtime/src/main/java/com/tns/Module.java index 912086414..929dfba69 100644 --- a/platforms/android/test-app/runtime/src/main/java/com/tns/Module.java +++ b/platforms/android/test-app/runtime/src/main/java/com/tns/Module.java @@ -192,28 +192,45 @@ private static File resolveFromFileOrDirectory(String baseDir, String path, File return foundModule; } + // Probe order for extensionless specifiers and directory indexes. It + // matches the Apple runtime so a project resolves the same file on both. + private static final String[] SCRIPT_EXTENSIONS = new String[]{".mjs", ".js", ".cjs"}; + + private static boolean isScriptFile(String fileName) { + for (String extension : SCRIPT_EXTENSIONS) { + if (fileName.endsWith(extension)) { + return true; + } + } + return false; + } + + private static File existingFile(File candidate) { + try { + File canonicalFile = candidate.getCanonicalFile(); + if (canonicalFile.exists() && canonicalFile.isFile()) { + return candidate; + } + } catch (IOException e) { + // treated as missing + } + return null; + } + //tries to load the path as a file, returns null if that's not possible private static File loadAsFile(File path) { - String fallbackExtension; - - boolean isJSFile = path.getName().endsWith(".js"); - boolean isSOFile = path.getName().endsWith(".so"); - boolean isJSONFile = path.getName().endsWith(".json"); + String fileName = path.getName(); + boolean hasKnownExtension = isScriptFile(fileName) || fileName.endsWith(".so") || fileName.endsWith(".json"); - if (isJSFile || isJSONFile || isSOFile) { - fallbackExtension = ""; - } else { - fallbackExtension = ".js"; + if (hasKnownExtension) { + return existingFile(path); } - File foundFile = new File(path.getAbsolutePath() + fallbackExtension); - try { - File canonicalFile = foundFile.getCanonicalFile(); - if (canonicalFile.exists() && canonicalFile.isFile()) { + for (String extension : SCRIPT_EXTENSIONS) { + File foundFile = existingFile(new File(path.getAbsolutePath() + extension)); + if (foundFile != null) { return foundFile; } - } catch (IOException e) { - // return null } return null; @@ -248,14 +265,11 @@ private static File loadAsDirectory(String baseDir, String currentPath, File pat } } - //fallback to index js - foundFile = new File(path, "index.js"); - try { - if (foundFile.getCanonicalFile().exists()) { + for (String extension : SCRIPT_EXTENSIONS) { + foundFile = existingFile(new File(path, "index" + extension)); + if (foundFile != null) { return foundFile; } - } catch (IOException e) { - return null; } //TODO: plamen5kov: add later if necessary From 34eb18c77f4b4fb949f9242656e55e34e7dc2790 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Mon, 7 Sep 2026 15:21:11 -0700 Subject: [PATCH 4/4] fix(ios): build extended classes without resolving their name through globals extendNativeApiClass created the JS value for a freshly registered class via makeNativeClassValue, which falls back to a by-name global lookup. core publishes its delegate constructors on global (global.SceneDelegate) and a TypeScript constructor inherits kind/runtimeName from its base wrapper, so the lookup returned the base class: the scene delegate materialized as UIResponder and UIKit rejected it ("delegateClass must conform to UISceneDelegate"). The new class value is now created by pointer only. --- .../ffi/objc/shared/bridge/ClassBuilder.mm | 2 +- .../ffi/objc/shared/bridge/ObjCBridge.mm | 3 +++ .../objc/shared/bridge/host_objects/Class.mm | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm b/NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm index 761fe1680..c0946770a 100644 --- a/NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm +++ b/NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm @@ -729,7 +729,7 @@ throw JSError(runtime, newSymbol.name = className; newSymbol.runtimeName = className; newSymbol.superclassOffset = baseSymbol.offset; - return makeNativeClassValue(runtime, bridge, std::move(newSymbol)); + return makeExtendedNativeClassValue(runtime, bridge, std::move(newSymbol)); } Value invokeNativeApiBaseMethod( diff --git a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm index 5baecf609..a7eeb4128 100644 --- a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm +++ b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm @@ -2363,6 +2363,9 @@ Value makeNativeObjectValue(Runtime& runtime, Value makeNativeClassValue(Runtime& runtime, const std::shared_ptr& bridge, NativeApiSymbol symbol); +Value makeExtendedNativeClassValue(Runtime& runtime, + const std::shared_ptr& bridge, + NativeApiSymbol symbol); Object symbolToObject(Runtime& runtime, const NativeApiSymbol& symbol) { Object result(runtime); diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm index e74c4032b..18cc277af 100644 --- a/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm @@ -485,6 +485,23 @@ Value makeNativeClassValue(Runtime& runtime, std::make_shared(bridge, std::move(symbol))); } +// For a class the runtime just registered. Unlike makeNativeClassValue this +// never resolves by name: a global of the same name whose `kind` reads "class" +// may be the JS constructor being extended (it inherits that from its base +// wrapper through extendStatics), and taking it would hand back the base class. +Value makeExtendedNativeClassValue(Runtime& runtime, + const std::shared_ptr& bridge, + NativeApiSymbol symbol) { + Class cls = objc_lookUpClass(symbol.runtimeName.c_str()); + Value cachedClass = bridge->findClassValue(runtime, cls); + if (!cachedClass.isUndefined()) { + return cachedClass; + } + return Object::createFromHostObject( + runtime, + std::make_shared(bridge, std::move(symbol))); +} + Protocol* lookupProtocolByNativeName(const std::string& name) { Protocol* protocol = objc_getProtocol(name.c_str()); if (protocol != nullptr) {