From 1d7f9d2a7a8d6fd0e046d725b7006a47f3ab3fc4 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 7 Jun 2026 15:41:35 -0700 Subject: [PATCH 1/2] feat: reloadApplication for JS bundle restart without restarting app process Helpful for programmatic reset of JS isolate for clean restart of JS application as well as OTA (over-the-air) updates without restarting the entire app process. --- NativeScript/NativeScript.h | 7 +++++ NativeScript/NativeScript.mm | 53 +++++++++++++++++++++++++++++++++ NativeScript/runtime/Runtime.h | 9 ++++++ NativeScript/runtime/Runtime.mm | 36 ++++++++++++++++++++++ 4 files changed, 105 insertions(+) diff --git a/NativeScript/NativeScript.h b/NativeScript/NativeScript.h index f9bcc9f1..de5e40ee 100644 --- a/NativeScript/NativeScript.h +++ b/NativeScript/NativeScript.h @@ -26,3 +26,10 @@ - (bool)liveSync; @end + +@interface NativeScriptRuntime : NSObject + ++ (BOOL)reloadApplication; ++ (BOOL)reloadApplication:(NSString*)baseDir; + +@end diff --git a/NativeScript/NativeScript.mm b/NativeScript/NativeScript.mm index 11f8ae80..6ccdacef 100644 --- a/NativeScript/NativeScript.mm +++ b/NativeScript/NativeScript.mm @@ -24,6 +24,21 @@ @implementation Config @end +static Config* CopyConfig(Config* config) { + Config* copy = [[Config alloc] init]; + copy.BaseDir = config.BaseDir; + copy.ApplicationPath = config.ApplicationPath; + copy.MetadataPtr = config.MetadataPtr; + copy.IsDebug = config.IsDebug; + copy.LogToSystemConsole = config.LogToSystemConsole; + copy.ArgumentsCount = config.ArgumentsCount; + copy.Arguments = config.Arguments; + return copy; +} + +static NativeScript* currentNativeScript; +static Config* currentConfig; + @implementation NativeScript extern char defaultStartOfMetadataSection __asm("section$start$__DATA$__TNSMetadata"); @@ -82,6 +97,9 @@ - (void)shutdownRuntime { - (instancetype)initializeWithConfig:(Config*)config { if (self = [super init]) { + currentNativeScript = self; + currentConfig = CopyConfig(config); + RuntimeConfig.BaseDir = [config.BaseDir UTF8String]; if (config.ApplicationPath != nil) { RuntimeConfig.ApplicationPath = @@ -98,6 +116,15 @@ - (instancetype)initializeWithConfig:(Config*)config { RuntimeConfig.IsDebug = [config IsDebug]; RuntimeConfig.LogToSystemConsole = [config LogToSystemConsole]; + // Connect the JS-exposed `NativeScriptRuntime.reloadApplication(baseDir?)` + // global (registered by the runtime) to the Objective-C implementation below. + tns::SetReloadApplicationHook([](const std::string& baseDir) -> bool { + NSString* dir = baseDir.empty() + ? nil + : [NSString stringWithUTF8String:baseDir.c_str()]; + return [NativeScriptRuntime reloadApplication:dir] == YES; + }); + Runtime::Initialize(); runtime_ = nullptr; runtime_ = std::make_unique(); @@ -135,3 +162,29 @@ - (void)restartWithConfig:(Config*)config { } @end + +@implementation NativeScriptRuntime + ++ (BOOL)reloadApplication { + return [self reloadApplication:nil]; +} + ++ (BOOL)reloadApplication:(NSString*)baseDir { + if (currentNativeScript == nil || currentConfig == nil) { + return NO; + } + + Config* config = CopyConfig(currentConfig); + if (baseDir != nil && [baseDir length] > 0) { + config.BaseDir = baseDir; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + [currentNativeScript restartWithConfig:config]; + [currentNativeScript runMainApplication]; + }); + + return YES; +} + +@end diff --git a/NativeScript/runtime/Runtime.h b/NativeScript/runtime/Runtime.h index 92d95dba..4370f2e1 100644 --- a/NativeScript/runtime/Runtime.h +++ b/NativeScript/runtime/Runtime.h @@ -8,8 +8,15 @@ #include "SpinLock.h" #include "libplatform/libplatform.h" +#include +#include + namespace tns { +using ReloadApplicationHook = std::function; +void SetReloadApplicationHook(ReloadApplicationHook hook); +bool InvokeReloadApplicationHook(const std::string& baseDir); + class Runtime { public: Runtime(); @@ -67,6 +74,8 @@ class Runtime { void DefineCollectFunction(v8::Local context); void DefineNativeScriptVersion(v8::Isolate* isolate, v8::Local globalTemplate); + void DefineNativeScriptRuntime(v8::Isolate* isolate, + v8::Local globalTemplate); void DefinePerformanceObject(v8::Isolate* isolate, v8::Local globalTemplate); void DefineTimeMethod(v8::Isolate* isolate, diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index 2de309cc..1c0d0b41 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -292,6 +292,7 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { tns::binding::CreateInternalBindingTemplates(isolate, globalTemplateFunction); Local globalTemplate = ObjectTemplate::New(isolate, globalTemplateFunction); DefineNativeScriptVersion(isolate, globalTemplate); + DefineNativeScriptRuntime(isolate, globalTemplate); // Worker::Init(isolate, globalTemplate, isWorker); DefinePerformanceObject(isolate, globalTemplate); @@ -573,6 +574,41 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { ToV8String(isolate, STRINGIZE_VALUE_OF(NATIVESCRIPT_VERSION)), readOnlyFlags); } +static ReloadApplicationHook reloadApplicationHook_; + +void SetReloadApplicationHook(ReloadApplicationHook hook) { + reloadApplicationHook_ = std::move(hook); +} + +bool InvokeReloadApplicationHook(const std::string& baseDir) { + if (!reloadApplicationHook_) { + return false; + } + return reloadApplicationHook_(baseDir); +} + +// API to trigger application reload from JS without restarting the application process. +// Exposes `global.NativeScriptRuntime.reloadApplication(baseDir?)` to JS. +// `NativeScriptRuntime` class is part of the runtime framework and +// is intentionally excluded from metadata generation, so it is not reachable +// from JS on its own. +void Runtime::DefineNativeScriptRuntime(Isolate* isolate, Local globalTemplate) { + Local runtimeTemplate = ObjectTemplate::New(isolate); + + Local reloadTemplate = + FunctionTemplate::New(isolate, [](const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + std::string baseDir; + if (info.Length() > 0 && info[0]->IsString()) { + baseDir = tns::ToString(isolate, info[0]); + } + info.GetReturnValue().Set(tns::InvokeReloadApplicationHook(baseDir)); + }); + runtimeTemplate->Set(ToV8String(isolate, "reloadApplication"), reloadTemplate); + + globalTemplate->Set(ToV8String(isolate, "NativeScriptRuntime"), runtimeTemplate); +} + void Runtime::DefineTimeMethod(v8::Isolate* isolate, v8::Local globalTemplate) { Local timeFunctionTemplate = FunctionTemplate::New(isolate, [](const FunctionCallbackInfo& info) { From fc0e142454498fbd91d147a65022ff988c978edf Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Mon, 20 Jul 2026 14:32:13 -0700 Subject: [PATCH 2/2] feat: survive dead-isolate dispatch after reloadApplication Instances of classes built by a previous isolate (e.g. a JS-extended UIApplicationDelegate or UISceneDelegate) outlive the isolate, and UIKit keeps dispatching to them after a soft reboot: - log dropped method callbacks (class + selector) behind logScriptLoading in ArgConverter::MethodCallback so the failure mode is visible instead of a silent no-op - add the same IsolateWrapper validity check to JS-implemented property getters/setters in ClassBuilder, which previously locked the disposed isolate (crash when UIKit queried `window` on a stale delegate) - expose NativeScriptRuntime.reloadCount, incremented by restartWithConfig, so @nativescript/core can detect a reloaded instance and reattach the UIApplication delegate and UIScene delegates from the new bundle --- NativeScript/NativeScript.mm | 3 +++ NativeScript/runtime/ArgConverter.mm | 10 ++++++++++ NativeScript/runtime/ClassBuilder.h | 3 +++ NativeScript/runtime/ClassBuilder.mm | 18 ++++++++++++++++++ NativeScript/runtime/DevFlags.h | 9 +++++++++ NativeScript/runtime/DevFlags.mm | 19 +++++++++++++++++++ NativeScript/runtime/Runtime.h | 9 +++++++++ NativeScript/runtime/Runtime.mm | 13 +++++++++++++ 8 files changed, 84 insertions(+) diff --git a/NativeScript/NativeScript.mm b/NativeScript/NativeScript.mm index 6ccdacef..dfc3d2cd 100644 --- a/NativeScript/NativeScript.mm +++ b/NativeScript/NativeScript.mm @@ -157,6 +157,9 @@ - (instancetype)initWithConfig:(Config*)config { } - (void)restartWithConfig:(Config*)config { + // Incremented before the new isolate boots so its global template bakes in + // the correct `NativeScriptRuntime.reloadCount` value. + tns::IncrementRuntimeReloadCount(); [self shutdownRuntime]; [self initializeWithConfig:config]; } diff --git a/NativeScript/runtime/ArgConverter.mm b/NativeScript/runtime/ArgConverter.mm index bd6956c6..90629b09 100644 --- a/NativeScript/runtime/ArgConverter.mm +++ b/NativeScript/runtime/ArgConverter.mm @@ -1,6 +1,7 @@ #include "ArgConverter.h" #include #include +#include "DevFlags.h" #include "DictionaryAdapter.h" #include "Helpers.h" #include "Interop.h" @@ -134,6 +135,15 @@ Isolate* isolate = data->isolateWrapper_.Isolate(); if (!data->isolateWrapper_.IsValid()) { + // Instance methods carry (self, _cmd) as the first two ffi args; blocks and + // function pointers do not, so only dereference them when present. + if (data->initialParamIndex_ == 2) { + id self_ = *static_cast(argValues[0]); + SEL cmd = *static_cast(argValues[1]); + LogDroppedDeadIsolateCallback((__bridge void*)self_, (void*)cmd); + } else { + LogDroppedDeadIsolateCallback(nullptr, nullptr); + } memset(retValue, 0, cif->rtype->size); return; } diff --git a/NativeScript/runtime/ClassBuilder.h b/NativeScript/runtime/ClassBuilder.h index 087bd266..59dce737 100644 --- a/NativeScript/runtime/ClassBuilder.h +++ b/NativeScript/runtime/ClassBuilder.h @@ -2,6 +2,7 @@ #define ClassBuilder_h #include "Common.h" +#include "IsolateWrapper.h" #include "Metadata.h" namespace tns { @@ -10,11 +11,13 @@ struct PropertyCallbackContext { public: PropertyCallbackContext(v8::Isolate* isolate, std::shared_ptr> callback, std::shared_ptr> implementationObject, const PropertyMeta* meta) : isolate_(isolate), + isolateWrapper_(isolate), callback_(callback), implementationObject_(implementationObject), meta_(meta) { } v8::Isolate* isolate_; + IsolateWrapper isolateWrapper_; std::shared_ptr> callback_; std::shared_ptr> implementationObject_; const PropertyMeta* meta_; diff --git a/NativeScript/runtime/ClassBuilder.mm b/NativeScript/runtime/ClassBuilder.mm index 49c9176d..56efa567 100644 --- a/NativeScript/runtime/ClassBuilder.mm +++ b/NativeScript/runtime/ClassBuilder.mm @@ -4,6 +4,7 @@ #include #include "ArgConverter.h" #include "Caches.h" +#include "DevFlags.h" #include "FastEnumerationAdapter.h" #include "Helpers.h" #include "Interop.h" @@ -892,6 +893,17 @@ FFIMethodCallback getterCallback = [](ffi_cif* cif, void* retValue, void** argValues, void* userData) { PropertyCallbackContext* context = static_cast(userData); + if (!context->isolateWrapper_.IsValid()) { + // The isolate that implemented this property is gone (e.g. after + // reloadApplication). UIKit may still query stale delegate instances + // (like `window` on a JS-extended UIApplicationDelegate); bail with a + // zeroed return instead of locking a disposed isolate. + id self_ = *static_cast(argValues[0]); + SEL cmd = *static_cast(argValues[1]); + LogDroppedDeadIsolateCallback((__bridge void*)self_, (void*)cmd); + memset(retValue, 0, cif->rtype->size); + return; + } v8::Locker locker(context->isolate_); Isolate::Scope isolate_scope(context->isolate_); HandleScope handle_scope(context->isolate_); @@ -926,6 +938,12 @@ FFIMethodCallback setterCallback = [](ffi_cif* cif, void* retValue, void** argValues, void* userData) { PropertyCallbackContext* context = static_cast(userData); + if (!context->isolateWrapper_.IsValid()) { + id self_ = *static_cast(argValues[0]); + SEL cmd = *static_cast(argValues[1]); + LogDroppedDeadIsolateCallback((__bridge void*)self_, (void*)cmd); + return; + } v8::Locker locker(context->isolate_); Isolate::Scope isolate_scope(context->isolate_); HandleScope handle_scope(context->isolate_); diff --git a/NativeScript/runtime/DevFlags.h b/NativeScript/runtime/DevFlags.h index 01533ae1..205d2d1a 100644 --- a/NativeScript/runtime/DevFlags.h +++ b/NativeScript/runtime/DevFlags.h @@ -12,6 +12,15 @@ namespace tns { // Controlled by package.json setting: "logScriptLoading": true|false bool IsScriptLoadingLogEnabled(); +// Logs (behind IsScriptLoadingLogEnabled) that a native->JS callback was +// dropped because the isolate that created it has been disposed (e.g. after +// NativeScriptRuntime.reloadApplication / restartWithConfig). Without this, +// UIKit dispatch to instances of classes built by a previous isolate (such as +// a JS-extended UIApplicationDelegate or UISceneDelegate) silently no-ops. +// `target` is an ObjC `id` and `selector` a `SEL`; either may be null when +// the callback is a block or C function pointer rather than a method. +void LogDroppedDeadIsolateCallback(void* target, void* selector); + // Security config // In debug mode (RuntimeConfig.IsDebug): always returns true. diff --git a/NativeScript/runtime/DevFlags.mm b/NativeScript/runtime/DevFlags.mm index 70d4c2bb..fb14927d 100644 --- a/NativeScript/runtime/DevFlags.mm +++ b/NativeScript/runtime/DevFlags.mm @@ -1,4 +1,5 @@ #import +#import #include "DevFlags.h" #include "Runtime.h" @@ -13,6 +14,24 @@ bool IsScriptLoadingLogEnabled() { return value ? [value boolValue] : false; } +void LogDroppedDeadIsolateCallback(void* target, void* selector) { + if (!IsScriptLoadingLogEnabled()) { + return; + } + + if (target != nullptr && selector != nullptr) { + id self_ = (__bridge id)target; + SEL cmd = (SEL)selector; + NSLog(@"NativeScript: dropping call to -[%s %s] because the JS isolate that implemented it " + @"was disposed (e.g. after reloadApplication); reassign the delegate/callback from the " + @"new bundle to restore dispatch", + object_getClassName(self_), sel_getName(cmd)); + } else { + NSLog(@"NativeScript: dropping a native callback (block or function pointer) because the JS " + @"isolate that implemented it was disposed (e.g. after reloadApplication)"); + } +} + // Security config static std::once_flag s_securityConfigInitFlag; diff --git a/NativeScript/runtime/Runtime.h b/NativeScript/runtime/Runtime.h index 4370f2e1..b05463fb 100644 --- a/NativeScript/runtime/Runtime.h +++ b/NativeScript/runtime/Runtime.h @@ -17,6 +17,15 @@ using ReloadApplicationHook = std::function; void SetReloadApplicationHook(ReloadApplicationHook hook); bool InvokeReloadApplicationHook(const std::string& baseDir); +// Number of times the runtime has been soft-rebooted in this process +// (restartWithConfig / reloadApplication). 0 for the first boot. Exposed to JS +// as `NativeScriptRuntime.reloadCount` so application code (e.g. +// @nativescript/core) can detect a reloaded instance and reattach native +// delegates (UIApplication delegate, UIScene delegates) that are still pinned +// to classes created by the previous isolate. +void IncrementRuntimeReloadCount(); +int GetRuntimeReloadCount(); + class Runtime { public: Runtime(); diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index 1c0d0b41..e67ec08f 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -587,6 +587,12 @@ bool InvokeReloadApplicationHook(const std::string& baseDir) { return reloadApplicationHook_(baseDir); } +static std::atomic runtimeReloadCount_{0}; + +void IncrementRuntimeReloadCount() { runtimeReloadCount_++; } + +int GetRuntimeReloadCount() { return runtimeReloadCount_.load(); } + // API to trigger application reload from JS without restarting the application process. // Exposes `global.NativeScriptRuntime.reloadApplication(baseDir?)` to JS. // `NativeScriptRuntime` class is part of the runtime framework and @@ -606,6 +612,13 @@ bool InvokeReloadApplicationHook(const std::string& baseDir) { }); runtimeTemplate->Set(ToV8String(isolate, "reloadApplication"), reloadTemplate); + // 0 on first boot, incremented on every in-process runtime restart. Lets JS + // (e.g. @nativescript/core) detect it is running inside a reloaded instance + // and reattach native delegates that UIKit still dispatches to old-bundle + // instances of (UIApplication delegate, UIScene delegates). + runtimeTemplate->Set(ToV8String(isolate, "reloadCount"), + v8::Number::New(isolate, tns::GetRuntimeReloadCount())); + globalTemplate->Set(ToV8String(isolate, "NativeScriptRuntime"), runtimeTemplate); }