From bcb87c9906de1a01e2e32cdee7113ef7684bc2d9 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 5 Jun 2026 17:03:57 -0700 Subject: [PATCH 1/3] fix(ios): re-attach root view after in-process runtime reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the runtime is reloaded in-process (NativeScriptRuntime.reloadApplication, used for OTA soft reboots) the app re-runs main in a fresh JS isolate while the process stays alive. iOSApplication.run() sees a live UIApplication and routes to runAsEmbeddedApp() - The previous UIWindow was deallocated along with the old isolate (it was retained only by the JS-side SceneDelegate._window), and sceneWillConnectToSession — which creates the window — does not re-fire on a reload. getWindow() then returned null and runAsEmbeddedApp() bailed early, leaving a black screen. - With no embedder delegate (NativeScript owns the UIApplication), the new root was presented modally over the old, now-dead root controller instead of replacing it — leaking the old controller on every reload and breaking when an overlay is mid-presentation. runAsEmbeddedApp() now: - recreates a window bound to the active UIWindowScene when none is found (initWithWindowScene + _setWindowForScene/_setupWindowForScene); - moves the rootViewController guard into the embedder-only branch, since a freshly recreated window has no controller yet; - replaces the window root via setWindowRootView() for the NativeScript-owned case instead of modal-presenting over the stale root. --- packages/core/application/application.ios.ts | 45 +++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index 8f44205b25..e5b8c735de 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -331,17 +331,43 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication this._rootView = rootView; setRootView(rootView); // Attach to the existing iOS app - const window = getWindow() as UIWindow; + let window = getWindow() as UIWindow; if (!window) { - return; + // In-process soft reboot with OTAs. + // Original UIWindow is deallocated when the old JS isolate is torn down. + // Recreate a window bound to the active UIWindowScene so the + // root has somewhere to attach. + const app = UIApplication.sharedApplication; + const all = app && app.connectedScenes ? app.connectedScenes.allObjects : null; + let targetScene: UIWindowScene; + if (all) { + for (let i = 0; i < all.count; i++) { + const s = all.objectAtIndex(i) as UIWindowScene; + // Only UIWindowScene exposes `windows`; prefer a foreground-active one. + if (s && typeof s.windows !== 'undefined') { + targetScene = s; + if (s.activationState === UISceneActivationState.ForegroundActive) { + break; + } + } + } + } + if (targetScene) { + window = UIWindow.alloc().initWithWindowScene(targetScene); + this._setWindowForScene(window, targetScene); + this._setupWindowForScene?.(window, targetScene); + } } - const rootController = window.rootViewController; - if (!rootController) { + if (!window) { return; } + // May be null on a freshly recreated window — expected; the replace-root + // path below sets it. Only the embedder path needs an existing controller. + const rootController = window.rootViewController; + const controller = this.getViewController(rootView); const embedderDelegate = NativeScriptEmbedder.sharedInstance().delegate; @@ -360,11 +386,18 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication }); if (embedderDelegate) { + // Embed into host app. + // present over the host's existing root view controller. + if (!rootController) { + return; + } this.setViewControllerView(rootView); embedderDelegate.presentNativeScriptApp(controller); } else { - const visibleVC = iosUtils.getVisibleViewController(rootController); - visibleVC.presentViewControllerAnimatedCompletion(controller, true, null); + // No embedder delegate = NativeScript owns the UIApplication. + // Attach the root to the window. + this.setViewControllerView(rootView); + this.setWindowRootView(window, rootView); } this.initRootView(rootView); From 51b957ab0b71682a35b2df03e72dc859d4c5cce2 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Tue, 9 Jun 2026 12:09:01 -0700 Subject: [PATCH 2/3] chore: apply suggestions from code review Co-authored-by: Nathan Walker --- packages/core/application/application.ios.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index e5b8c735de..69496306e9 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -367,9 +367,14 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication // May be null on a freshly recreated window — expected; the replace-root // path below sets it. Only the embedder path needs an existing controller. const rootController = window.rootViewController; + const embedderDelegate = NativeScriptEmbedder.sharedInstance().delegate; + + // Embed into host app requires an existing root view controller + if (embedderDelegate && !rootController) { + return; + } const controller = this.getViewController(rootView); - const embedderDelegate = NativeScriptEmbedder.sharedInstance().delegate; rootView._setupAsRootView({}); @@ -388,9 +393,6 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication if (embedderDelegate) { // Embed into host app. // present over the host's existing root view controller. - if (!rootController) { - return; - } this.setViewControllerView(rootView); embedderDelegate.presentNativeScriptApp(controller); } else { From 6c1479b255b82cd77e674a27ec2ce35a9a4bc3e7 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Mon, 20 Jul 2026 13:52:31 -0700 Subject: [PATCH 3/3] feat(ios): handle scene delegates with soft reboot --- packages/core/application/application.ios.ts | 97 +++++++++++++++++++- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index 69496306e9..04152fce5e 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -127,6 +127,16 @@ function supportsMultipleScenes(): boolean { return UIApplication.sharedApplication?.supportsMultipleScenes; } +/** + * Number of times the JS runtime has been soft-rebooted in this process via + * NativeScriptRuntime.reloadApplication / restartWithConfig. 0 on first boot. + * Provided as a global by the iOS runtime (v9+); older runtimes report 0. + */ +function getRuntimeReloadCount(): number { + const runtime = (globalThis as any).NativeScriptRuntime; + return runtime && typeof runtime.reloadCount === 'number' ? runtime.reloadCount : 0; +} + @NativeClass class Responder extends UIResponder implements UIApplicationDelegate { get window(): UIWindow { @@ -269,6 +279,13 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication private _notificationObservers: NotificationObserver[] = []; + // Strong references to delegates recreated after an in-process soft reboot + // (NativeScriptRuntime.reloadApplication). UIApplication.delegate is an + // `assign` property and UIScene keeps its own reference to the delegate we + // replace, so without these the fresh instances would be deallocated. + private _softRebootAppDelegate: UIApplicationDelegate; + private _softRebootSceneDelegates = new Map(); + displayedOnce = false; displayedLinkTarget: CADisplayLinkTarget; displayedLink: CADisplayLink; @@ -323,6 +340,8 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } private runAsEmbeddedApp() { + this._reattachNativeDelegatesAfterSoftReboot(); + // TODO: this rootView should be held alive until rootController dismissViewController is called. const rootView = this.createRootView(this._rootView, true); if (!rootView) { @@ -357,6 +376,13 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication window = UIWindow.alloc().initWithWindowScene(targetScene); this._setWindowForScene(window, targetScene); this._setupWindowForScene?.(window, targetScene); + + // If the scene's delegate was recreated after a soft reboot, point it + // at the new window so `scene.delegate.window` queries resolve. + const freshSceneDelegate = this._softRebootSceneDelegates.get(targetScene); + if (freshSceneDelegate) { + freshSceneDelegate.window = window; + } } } @@ -367,12 +393,12 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication // May be null on a freshly recreated window — expected; the replace-root // path below sets it. Only the embedder path needs an existing controller. const rootController = window.rootViewController; - const embedderDelegate = NativeScriptEmbedder.sharedInstance().delegate; + const embedderDelegate = NativeScriptEmbedder.sharedInstance().delegate; - // Embed into host app requires an existing root view controller - if (embedderDelegate && !rootController) { - return; - } + // Embed into host app requires an existing root view controller + if (embedderDelegate && !rootController) { + return; + } const controller = this.getViewController(rootView); @@ -406,6 +432,67 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication this.notifyAppStarted(); } + /** + * After an in-process soft reboot (NativeScriptRuntime.reloadApplication / + * restartWithConfig), the Objective-C delegate classes created by the + * previous JS isolate still exist and UIKit keeps dispatching to their + * now-inert instances: their method callbacks bail out because the isolate + * that implemented them is gone. Notification-center observers are + * re-registered by the new isolate, but delegate-based dispatch (custom + * UIApplicationDelegate methods like push-token/openURL callbacks, and the + * UIScene delegates used by scene-lifecycle apps) stays pinned to the old + * bundle. Recreate those delegates from this bundle's classes and re-point + * UIKit at them. + */ + private _reattachNativeDelegatesAfterSoftReboot(): void { + if (getRuntimeReloadCount() <= 0) { + // First boot: UIApplicationMain (or the host app) set up delegates. + return; + } + + if (isEmbedded()) { + // The host app owns the UIApplication delegate; never touch it. + return; + } + + const app = UIApplication.sharedApplication; + if (!app) { + return; + } + + // Fresh application delegate from the new bundle. Assigning `delegate` + // does not retain (unlike the UIApplicationMain launch path), so keep a + // strong reference ourselves. + this.delegate ??= Responder as any; + const freshDelegate = (this.delegate).new() as UIApplicationDelegate; + this._softRebootAppDelegate = freshDelegate; + app.delegate = freshDelegate; + + // Re-point already-connected scenes at fresh scene delegates so scene + // lifecycle and user-implemented scene delegate methods (shortcuts, + // openURLContexts, userActivity continuation, etc.) reach this isolate. + // Newly connecting scenes are covered by the fresh application delegate's + // applicationConfigurationForConnectingSceneSessionOptions, which returns + // this bundle's SceneDelegate class. + if (this.supportsScenes()) { + this._softRebootSceneDelegates.clear(); + const scenes = app.connectedScenes?.allObjects; + for (let i = 0; scenes && i < scenes.count; i++) { + const scene = scenes.objectAtIndex(i); + if (!(scene instanceof UIWindowScene)) { + continue; + } + const freshSceneDelegate = SceneDelegate.new() as UIWindowSceneDelegate; + scene.delegate = freshSceneDelegate; + this._softRebootSceneDelegates.set(scene, freshSceneDelegate); + } + } + + if (Trace.isEnabled()) { + Trace.write(`Reattached application delegate${this._softRebootSceneDelegates.size ? ` and ${this._softRebootSceneDelegates.size} scene delegate(s)` : ''} after soft reboot (reloadCount: ${getRuntimeReloadCount()})`, Trace.categories.NativeLifecycle); + } + } + private getViewController(rootView: View): UIViewController { let viewController: UIViewController = rootView.viewController || rootView.ios;