From 3bf0c36b3f19e0485fb82c0f270dc7cb006ae89a Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 27 Dec 2025 13:04:32 -0800 Subject: [PATCH 01/19] feat: support Vite HMR --- packages/angular/src/lib/application.ts | 69 ++++++++++++++++--------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/packages/angular/src/lib/application.ts b/packages/angular/src/lib/application.ts index f4eb37d8..54b85158 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -457,30 +457,53 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { if (oldAddEventListener) { global.NativeScriptGlobals.events.addEventListener = oldAddEventListener; } - if (import.meta['webpackHot']) { - // handle HMR Application.run - global['__dispose_app_ng_platform__'] = () => { - disposePlatform('hotreload'); - }; - global['__dispose_app_ng_modules__'] = () => { - disposeLastModules('hotreload'); - }; - global['__bootstrap_app_ng_modules__'] = () => { - bootstrapRoot('hotreload'); - }; - global['__cleanup_ng_hot__'] = () => { - Application.off(Application.launchEvent, launchCallback); - Application.off(Application.exitEvent, exitCallback); - disposeLastModules('hotreload'); + + // Detect HMR environment (webpack or Vite) + const isWebpackHot = !!import.meta['webpackHot']; + const isViteHot = !!import.meta['hot']; + const isHotReloadEnabled = isWebpackHot || isViteHot; + + // Always expose HMR globals for both webpack and Vite HMR support + // These allow the HMR runtime to properly dispose and re-bootstrap Angular + global['__dispose_app_ng_platform__'] = () => { + disposePlatform('hotreload'); + }; + global['__dispose_app_ng_modules__'] = () => { + disposeLastModules('hotreload'); + }; + global['__bootstrap_app_ng_modules__'] = () => { + bootstrapRoot('hotreload'); + }; + global['__cleanup_ng_hot__'] = () => { + Application.off(Application.launchEvent, launchCallback); + Application.off(Application.exitEvent, exitCallback); + disposeLastModules('hotreload'); + disposePlatform('hotreload'); + }; + global['__reboot_ng_modules__'] = (shouldDisposePlatform: boolean = false) => { + disposeLastModules('hotreload'); + if (shouldDisposePlatform) { disposePlatform('hotreload'); - }; - global['__reboot_ng_modules__'] = (shouldDisposePlatform: boolean = false) => { - disposeLastModules('hotreload'); - if (shouldDisposePlatform) { - disposePlatform('hotreload'); - } - bootstrapRoot('hotreload'); - }; + } + bootstrapRoot('hotreload'); + }; + + if (isWebpackHot) { + // Webpack-specific HMR handling + import.meta['webpackHot'].decline(); + + if (!Application.hasLaunched()) { + Application.run(); + return; + } + bootstrapRoot('hotreload'); + return; + } + + if (isViteHot) { + // Vite-specific HMR handling + // Vite HMR is handled by @nativescript/vite's HMR runtime + // which will call __reboot_ng_modules__ when needed if (!Application.hasLaunched()) { Application.run(); From 727de3c019cbef94e613a81281685e326394c407 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Mon, 30 Mar 2026 08:53:20 -0700 Subject: [PATCH 02/19] feat: vite hmr wip --- packages/angular/src/lib/application.ts | 93 +++- packages/zone-js/dist/connectivity.ts | 15 - packages/zone-js/dist/core.ts | 59 --- packages/zone-js/dist/events.ts | 69 --- packages/zone-js/dist/index.ts | 5 - packages/zone-js/dist/nativescript-globals.ts | 22 - packages/zone-js/dist/pre-zone-polyfills.ts | 12 - packages/zone-js/dist/trace-error.ts | 9 - packages/zone-js/dist/utils.ts | 437 ------------------ packages/zone-js/dist/xhr.ts | 203 -------- 10 files changed, 91 insertions(+), 833 deletions(-) delete mode 100644 packages/zone-js/dist/connectivity.ts delete mode 100644 packages/zone-js/dist/core.ts delete mode 100644 packages/zone-js/dist/events.ts delete mode 100644 packages/zone-js/dist/index.ts delete mode 100644 packages/zone-js/dist/nativescript-globals.ts delete mode 100644 packages/zone-js/dist/pre-zone-polyfills.ts delete mode 100644 packages/zone-js/dist/trace-error.ts delete mode 100644 packages/zone-js/dist/utils.ts delete mode 100644 packages/zone-js/dist/xhr.ts diff --git a/packages/angular/src/lib/application.ts b/packages/angular/src/lib/application.ts index 54b85158..8a8ceb5c 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -1,3 +1,4 @@ +import * as AngularCore from '@angular/core'; import { ApplicationRef, EnvironmentProviders, NgModuleRef, NgZone, PlatformRef, Provider } from '@angular/core'; import { Application, @@ -18,6 +19,11 @@ import { NativeScriptLoadingService } from './loading.service'; import { APP_ROOT_VIEW, DISABLE_ROOT_VIEW_HANDLING, NATIVESCRIPT_ROOT_MODULE_ID } from './tokens'; import { NativeScriptDebug } from './trace'; +// Store the original @angular/core module for HMR +// This is crucial because HMR imports a fresh @angular/core with empty LView tracking +// We need to use the original one that has the registered LViews +(globalThis as any).__NS_ANGULAR_CORE__ = AngularCore; + export interface AppLaunchView extends LayoutBase { // called when the animation is to begin startAnimation?: () => void; @@ -236,16 +242,20 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { let launchEventDone = true; let targetRootView: View = null; const setRootView = (ref: NgModuleRef | ApplicationRef | View) => { + console.log('[ng-hmr] setRootView called, bootstrapId:', bootstrapId, 'ref type:', ref?.constructor?.name); if (bootstrapId === -1) { // treat edge cases + console.log('[ng-hmr] setRootView: bootstrapId is -1, returning early'); return; } if (ref instanceof NgModuleRef || ref instanceof ApplicationRef) { if (ref.injector.get(DISABLE_ROOT_VIEW_HANDLING, false)) { + console.log('[ng-hmr] setRootView: DISABLE_ROOT_VIEW_HANDLING is true, returning'); return; } } else { if (ref['__disable_root_view_handling']) { + console.log('[ng-hmr] setRootView: __disable_root_view_handling is true, returning'); return; } } @@ -253,6 +263,7 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { NativeScriptDebug.bootstrapLog(`Setting RootView ${launchEventDone ? 'outside of' : 'during'} launch event`); // TODO: check for leaks when root view isn't properly destroyed if (ref instanceof View) { + console.log('[ng-hmr] setRootView: ref is View, launchEventDone:', launchEventDone); if (NativeScriptDebug.isLogEnabled()) { NativeScriptDebug.bootstrapLog(`Setting RootView to ${ref}`); } @@ -267,14 +278,35 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { } const view = ref.injector.get(APP_ROOT_VIEW) as AppHostView | View; const newRoot = view instanceof AppHostView ? view.content : view; + console.log('[ng-hmr] setRootView: view from injector:', view?.constructor?.name, 'newRoot:', newRoot?.constructor?.name); + console.log('[ng-hmr] setRootView: launchEventDone:', launchEventDone, 'embedded:', options.embedded); if (NativeScriptDebug.isLogEnabled()) { NativeScriptDebug.bootstrapLog(`Setting RootView to ${newRoot}`); } if (options.embedded) { + console.log('[ng-hmr] setRootView: calling Application.run (embedded)'); Application.run({ create: () => newRoot }); } else if (launchEventDone) { + console.log('[ng-hmr] setRootView: calling Application.resetRootView'); + console.log('[ng-hmr] setRootView: newRoot details:', { + type: newRoot?.constructor?.name, + nativeView: !!newRoot?.nativeView, + parent: newRoot?.parent?.constructor?.name, + childCount: (newRoot as any)?.getChildrenCount?.() ?? 'N/A', + }); Application.resetRootView({ create: () => newRoot }); + console.log('[ng-hmr] setRootView: Application.resetRootView returned'); + // Check root view after reset + setTimeout(() => { + const currentRoot = Application.getRootView(); + console.log('[ng-hmr] setRootView: after reset, getRootView:', { + type: currentRoot?.constructor?.name, + nativeView: !!currentRoot?.nativeView, + childCount: (currentRoot as any)?.getChildrenCount?.() ?? 'N/A', + }); + }, 100); } else { + console.log('[ng-hmr] setRootView: setting targetRootView (launch in progress)'); targetRootView = newRoot; } }; @@ -286,8 +318,10 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { setRootView(errorTextBox); }; const bootstrapRoot = (reason: NgModuleReason) => { + console.log('[ng-hmr] bootstrapRoot called, reason:', reason); try { bootstrapId = Date.now(); + console.log('[ng-hmr] bootstrapRoot: new bootstrapId:', bootstrapId); const currentBootstrapId = bootstrapId; let bootstrapped = false; let onMainBootstrap = () => { @@ -297,20 +331,70 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { () => options.appModuleBootstrap(reason).then( (ref) => { + console.log('[ng-hmr] appModuleBootstrap resolved, ref:', ref?.constructor?.name); + console.log('[ng-hmr] currentBootstrapId:', currentBootstrapId, 'bootstrapId:', bootstrapId); if (currentBootstrapId !== bootstrapId) { // this module is old and not needed anymore // this may happen when developer uses async app initializer and the user exits the app before this bootstraps + console.log('[ng-hmr] bootstrap ID mismatch, destroying ref'); ref.destroy(); return; } mainModuleRef = ref; + + // Expose ApplicationRef for HMR to trigger change detection + // Check for ApplicationRef by duck-typing since instanceof can fail across module realms + const refAny = ref as any; + const isAppRef = refAny && typeof refAny.tick === 'function' && Array.isArray(refAny.components); + console.log('[ng-hmr] ref type check: isAppRef=', isAppRef, 'has tick=', typeof refAny?.tick === 'function', 'has components=', Array.isArray(refAny?.components)); + + if (isAppRef) { + global['__NS_ANGULAR_APP_REF__'] = ref; + // Mark boot complete for the HMR system + global['__NS_HMR_BOOT_COMPLETE__'] = true; + + // Register bootstrapped components for HMR lookup + if (!global['__NS_ANGULAR_COMPONENTS__']) { + global['__NS_ANGULAR_COMPONENTS__'] = {}; + } + // Get the component class from the first bootstrapped component + console.log('[ng-hmr] ApplicationRef components count:', refAny.components?.length); + if (refAny.components && refAny.components.length > 0) { + const componentRef = refAny.components[0]; + console.log('[ng-hmr] componentRef:', componentRef?.constructor?.name); + console.log('[ng-hmr] componentRef.componentType:', componentRef?.componentType?.name); + + // For Angular 17+ standalone components, the component type is on componentRef.componentType + // For older Angular, try componentRef.instance.constructor + let componentType = componentRef?.componentType; + if (!componentType && componentRef?.instance) { + componentType = componentRef.instance.constructor; + } + + if (componentType && componentType.name) { + global['__NS_ANGULAR_COMPONENTS__'][componentType.name] = componentType; + console.log('[ng-hmr] Registered component for HMR:', componentType.name); + } else { + console.log('[ng-hmr] Could not get componentType name'); + } + } else { + console.log('[ng-hmr] No components in ApplicationRef'); + } + } else { + const appRef = ref.injector.get(ApplicationRef, null); + if (appRef) { + global['__NS_ANGULAR_APP_REF__'] = appRef; + // Mark boot complete for the HMR system + global['__NS_HMR_BOOT_COMPLETE__'] = true; + } + } - (ref instanceof ApplicationRef ? ref.components[0] : ref).onDestroy( + (isAppRef ? refAny.components[0] : ref).onDestroy( () => (mainModuleRef = mainModuleRef === ref ? null : mainModuleRef), ); updatePlatformRef(ref, reason); const styleTag = ref.injector.get(NATIVESCRIPT_ROOT_MODULE_ID); - (ref instanceof ApplicationRef ? ref.components[0] : ref).onDestroy(() => { + (isAppRef ? refAny.components[0] : ref).onDestroy(() => { removeTaggedAdditionalCSS(styleTag); }); bootstrapped = true; @@ -481,11 +565,16 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { disposePlatform('hotreload'); }; global['__reboot_ng_modules__'] = (shouldDisposePlatform: boolean = false) => { + console.log('[ng-hmr] __reboot_ng_modules__ called, shouldDisposePlatform:', shouldDisposePlatform); + console.log('[ng-hmr] current bootstrapId:', bootstrapId, 'mainModuleRef:', !!mainModuleRef); disposeLastModules('hotreload'); + console.log('[ng-hmr] after disposeLastModules, bootstrapId:', bootstrapId); if (shouldDisposePlatform) { disposePlatform('hotreload'); } + console.log('[ng-hmr] calling bootstrapRoot...'); bootstrapRoot('hotreload'); + console.log('[ng-hmr] bootstrapRoot returned, new bootstrapId:', bootstrapId); }; if (isWebpackHot) { diff --git a/packages/zone-js/dist/connectivity.ts b/packages/zone-js/dist/connectivity.ts deleted file mode 100644 index 8da43200..00000000 --- a/packages/zone-js/dist/connectivity.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* eslint-disable */ -import './core'; -import { Connectivity } from '@nativescript/core'; - -Zone.__load_patch('nativescript_connectivity', (global, zone, api) => { - api.patchMethod( - Connectivity, - 'startMonitoring', - (delegate, delegateName, name) => - function (self, args) { - const callback = args[0]; - return delegate.apply(self, [Zone.current.wrap(callback, 'NS Connectivity patch')]); - } - ); -}); diff --git a/packages/zone-js/dist/core.ts b/packages/zone-js/dist/core.ts deleted file mode 100644 index 8b0d1c02..00000000 --- a/packages/zone-js/dist/core.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* eslint-disable */ -import { patchClass, patchNativeScriptEventTarget } from './utils'; - -function isPropertyWritable(propertyDesc: any) { - if (!propertyDesc) { - return true; - } - - if (propertyDesc.writable === false) { - return false; - } - - return !(typeof propertyDesc.get === 'function' && typeof propertyDesc.set === 'undefined'); -} - -Zone.__load_patch('nativescript_patchMethod', (global, Zone, api) => { - api.patchMethod = function patchMethod(target: any, name: string, patchFn: (delegate: Function, delegateName: string, name: string) => (self: any, args: any[]) => any): Function | null { - let proto = target; - while (proto && !proto.hasOwnProperty(name)) { - proto = Object.getPrototypeOf(proto); - } - if (!proto && target[name]) { - // somehow we did not find it, but we can see it. This happens on IE for Window properties. - proto = target; - } - - const delegateName = Zone.__symbol__(name); - let delegate: Function | null = null; - if (proto && !proto.hasOwnProperty(delegateName)) { - delegate = proto[delegateName] = proto[name]; - // check whether proto[name] is writable - // some property is readonly in safari, such as HtmlCanvasElement.prototype.toBlob - const desc = proto && api.ObjectGetOwnPropertyDescriptor(proto, name); - if (isPropertyWritable(desc)) { - const patchDelegate = patchFn(delegate!, delegateName, name); - proto[name] = function () { - return patchDelegate(this, arguments as any); - }; - api.attachOriginToPatched(proto[name], delegate); - // if (shouldCopySymbolProperties) { - // copySymbolProperties(delegate, proto[name]); - // } - } - } - return delegate; - }; -}); - -Zone.__load_patch('nativescript_event_target_api', (g, z, api: any) => { - api.patchNativeScriptEventTarget = patchNativeScriptEventTarget; -}); - -Zone.__load_patch('nativescript_patch_class_api', (g, z, api) => { - api.patchClass = (className: string) => patchClass(className, api); -}); - -// Initialize zone microtask queue on main thread -// TODO: dive into the ios runtime (PromiseProxy) and find a better solution -Promise.resolve().then(() => {}); diff --git a/packages/zone-js/dist/events.ts b/packages/zone-js/dist/events.ts deleted file mode 100644 index 824c5b6c..00000000 --- a/packages/zone-js/dist/events.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* eslint-disable */ -import './core'; -import { Observable, View, Utils } from '@nativescript/core'; - -Zone.__load_patch('nativescript_observable_events', (g, z, api: any) => { - api.patchNativeScriptEventTarget(g, api, [Observable, Observable.prototype, View, View.prototype]); -}); - -Zone.__load_patch('nativescript_xhr_events', (g, z, api: any) => { - api.patchNativeScriptEventTarget(g, api, [XMLHttpRequest.prototype]); -}); - -// We're patching the Utils object instead of the actual js module -Zone.__load_patch('nativescript_mainThreadify', (global, zone, api) => { - api.patchMethod( - Utils, - 'mainThreadify', - (delegate, delegateName, name) => - function (self, args) { - const callback = args[0]; - return delegate.apply(self, [Zone.current.wrap(callback, 'NS mainThreadify patch')]); - } - ); -}); - -Zone.__load_patch('nativescript_executeOnMainThread', (global, zone, api) => { - api.patchMethod( - Utils, - 'executeOnMainThread', - (delegate, delegateName, name) => - function (self, args) { - const callback = args[0]; - return delegate.apply(self, [Zone.current.wrap(callback, 'NS executeOnMainThread patch')]); - } - ); -}); - -Zone.__load_patch('nativescript_dispatchToMainThread', (global, zone, api) => { - api.patchMethod( - Utils, - 'dispatchToMainThread', - (delegate, delegateName, name) => - function (self, args) { - const callback = args[0]; - return delegate.apply(self, [Zone.current.wrap(callback, 'NS dispatchToMainThread patch')]); - } - ); -}); - -Zone.__load_patch('nativescript_showModal', (global, zone, api) => { - api.patchMethod( - View.prototype, - 'showModal', - (delegate, delegateName, name) => - function (self, args) { - if (args.length === 2) { - const options = args[1]; - if (options.closeCallback) { - options.closeCallback = Zone.current.wrap(options.closeCallback, 'NS showModal patch'); - } - } else if (args.length > 3) { - args[3] = Zone.current.wrap(args[3], 'NS showModal patch'); - } - return delegate.apply(self, args); - } - ); -}); - -//! queueMacroTask should never be patched! We should consider it as a low level API to queue macroTasks which will be patched separately by other patches. diff --git a/packages/zone-js/dist/index.ts b/packages/zone-js/dist/index.ts deleted file mode 100644 index 330a1a51..00000000 --- a/packages/zone-js/dist/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import './core'; -import './nativescript-globals'; -import './events'; -import './xhr'; -import './connectivity'; diff --git a/packages/zone-js/dist/nativescript-globals.ts b/packages/zone-js/dist/nativescript-globals.ts deleted file mode 100644 index 13efd5e2..00000000 --- a/packages/zone-js/dist/nativescript-globals.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Zone.__load_patch('nativescript_MutationObserver', (global: any, Zone: ZoneType, api: _ZonePrivate) => { -// api.patchClass('MutationObserver'); -// api.patchClass('WebKitMutationObserver'); -// }); - -// Zone.__load_patch('nativescript_IntersectionObserver', (global: any, Zone: ZoneType, api: _ZonePrivate) => { -// api.patchClass('IntersectionObserver'); -// }); - -/* eslint-disable @typescript-eslint/no-explicit-any */ -Zone.__load_patch('nativescript_FileReader', (global: any, Zone: ZoneType, api: _ZonePrivate) => { - const reader = global['FileReader']; - if (reader) { - reader.prototype.onload = reader.prototype.onload || null; - reader.prototype.onerror = reader.prototype.onerror || null; - reader.prototype.onabort = reader.prototype.onabort || null; - reader.prototype.onloadend = reader.prototype.onloadend || null; - reader.prototype.onloadstart = reader.prototype.onloadstart || null; - reader.prototype.onprogress = reader.prototype.onprogress || null; - } - api.patchClass('FileReader'); -}); diff --git a/packages/zone-js/dist/pre-zone-polyfills.ts b/packages/zone-js/dist/pre-zone-polyfills.ts deleted file mode 100644 index 54999d1a..00000000 --- a/packages/zone-js/dist/pre-zone-polyfills.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const disabledPatches = [ - 'legacy', - 'EventTarget', - 'XHR', - 'MutationObserver', - 'IntersectionObserver', - 'FileReader', -]; - -for (const patch of disabledPatches) { - global[`__Zone_disable_${patch}`] = true; -} diff --git a/packages/zone-js/dist/trace-error.ts b/packages/zone-js/dist/trace-error.ts deleted file mode 100644 index c4a1fb21..00000000 --- a/packages/zone-js/dist/trace-error.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* eslint-disable */ -import { Trace } from '@nativescript/core'; - -Zone.__load_patch('nativescript_zone_to_trace_error', (global, zone, api) => { - zone[zone.__symbol__('unhandledPromiseRejectionHandler')] = (e) => { - Trace.error(e); - }; - zone[zone.__symbol__('ignoreConsoleErrorUncaughtError')] = true; -}); diff --git a/packages/zone-js/dist/utils.ts b/packages/zone-js/dist/utils.ts deleted file mode 100644 index 84df0d6b..00000000 --- a/packages/zone-js/dist/utils.ts +++ /dev/null @@ -1,437 +0,0 @@ -/* eslint-disable */ -const ZONE_SYMBOL_PREFIX = Zone.__symbol__(''); -const zoneSymbolEventNames: any = {}; -const ADD_EVENT_LISTENER_STR = 'addEventListener'; -const REMOVE_EVENT_LISTENER_STR = 'removeEventListener'; -function prepareEventNames(eventName: string, eventNameToString?: (eventName: string) => string) { - // const falseEventName = (eventNameToString ? eventNameToString(eventName) : eventName) + FALSE_STR; - // const trueEventName = (eventNameToString ? eventNameToString(eventName) : eventName) + TRUE_STR; - // const symbol = ZONE_SYMBOL_PREFIX + falseEventName; - // const symbolCapture = ZONE_SYMBOL_PREFIX + trueEventName; - // zoneSymbolEventNames[eventName] = {}; - // zoneSymbolEventNames[eventName][FALSE_STR] = symbol; - // zoneSymbolEventNames[eventName][TRUE_STR] = symbolCapture; - const symbol = ZONE_SYMBOL_PREFIX + (eventNameToString ? eventNameToString(eventName) : eventName) + 'false'; - zoneSymbolEventNames[eventName] = symbol; -} -interface NSTaskData { - thisArg?: any; - eventName?: string; - target?: any; - actualDelegate?: any; -} -interface ExtendedTaskData extends TaskData { - nsTaskData?: NSTaskData; -} - -interface ExtendedTask extends Task { - thisArg?: WeakRef; - eventName?: string; - target?: any; - customCallback?: any; - ranOnce?: boolean; -} -export interface PatchEventTargetOptions { - // validateHandler - vh?: (nativeDelegate: any, delegate: any, target: any, args: any) => boolean; - // addEventListener function name - add?: string; - // removeEventListener function name - rm?: string; - // once function name - once?: string; - // listeners function name - listeners?: string; - // removeAllListeners function name - rmAll?: string; - // check duplicate flag when addEventListener - chkDup?: boolean; - // return target flag when addEventListener - rt?: boolean; - // event compare handler - diff?: (task: any, delegate: any) => boolean; - // support passive or not - supportPassive?: boolean; - // get string from eventName (in nodejs, eventName maybe Symbol) - eventNameToString?: (eventName: any) => string; - // transfer eventName - transferEventName?: (eventName: string) => string; -} - -export function patchNativeScriptEventTarget(global: any, api: _ZonePrivate, apis?: any[], patchOptions?: PatchEventTargetOptions) { - const ADD_EVENT_LISTENER = (patchOptions && patchOptions.add) || ADD_EVENT_LISTENER_STR; - const REMOVE_EVENT_LISTENER = (patchOptions && patchOptions.rm) || REMOVE_EVENT_LISTENER_STR; - const ONCE = (patchOptions && patchOptions.once) || 'once'; - - const zoneSymbolAddEventListener = Zone.__symbol__(ADD_EVENT_LISTENER); - - const ADD_EVENT_LISTENER_SOURCE = '.' + ADD_EVENT_LISTENER + ':'; - - function patchNativeScriptEventTargetMethods(obj, patchOptions) { - if (!obj) { - return false; - } - const eventNameToString = patchOptions && patchOptions.eventNameToString; - // let proto = obj; - // while (proto && !proto.hasOwnProperty(ADD_EVENT_LISTENER)) { - // proto = Object.getPrototypeOf(proto); - // } - // if (!proto && obj[ADD_EVENT_LISTENER]) { - // // somehow we did not find it, but we can see it. This happens on IE for Window properties. - // proto = obj; - // } - - // if (!proto) { - // return false; - // } - // if (proto[zoneSymbolAddEventListener]) { - // return false; - // } - function compare(task: ExtendedTask, delegate: any, thisArg?: any) { - const taskThis = task.thisArg ? task.thisArg.get() : undefined; - if (!thisArg) { - thisArg = undefined; // keep consistent - } - return task.callback === delegate && taskThis === thisArg; - } - - const nativeAddListener = api.patchMethod( - obj, - ADD_EVENT_LISTENER, - (delegate, delegateName, name) => - function (originalTarget, originalArgs) { - const addSingleEvent = function (target, args) { - const eventName = args[0]; - const callback = args[1]; - const taskData: NSTaskData = {}; - const thisArg = (args.length > 1 && args[2]) || undefined; - taskData.target = target; - taskData.eventName = eventName; - taskData.thisArg = thisArg; - let symbolEventNames = zoneSymbolEventNames[eventName]; - if (!symbolEventNames) { - prepareEventNames(eventName, eventNameToString); - symbolEventNames = zoneSymbolEventNames[eventName]; - } - const symbolEventName = symbolEventNames; - let existingTasks = target[symbolEventName]; - let isExisting = false; - let checkDuplicate = false; - if (existingTasks) { - // already have task registered - isExisting = true; - if (checkDuplicate) { - for (let i = 0; i < existingTasks.length; i++) { - if (compare(existingTasks[i], delegate, taskData.thisArg)) { - // same callback, same capture, same event name, just return - return; - } - } - } - } else { - existingTasks = target[symbolEventName] = []; - } - const schedule = (task: Task) => { - const args2 = [taskData.eventName, task.invoke]; - if (taskData.thisArg) { - args2.push(taskData.thisArg); - } - delegate.apply(target, args2); - }; - const unschedule = (task: ExtendedTask) => { - const args2 = [task.eventName, task.invoke]; - if (task.thisArg) { - args2.push(task.thisArg.get()); - } - nativeRemoveListener.apply(target, args2); - }; - const data: ExtendedTaskData = { - nsTaskData: taskData, - }; - const objName = obj.name || obj?.constructor?.name; - const task: ExtendedTask = Zone.current.scheduleEventTask(objName + ':' + (eventNameToString ? eventNameToString(eventName) : eventName), callback, data, schedule, unschedule); - // should clear taskData.target to avoid memory leak - // issue, https://github.com/angular/angular/issues/20442 - taskData.target = null; - - // need to clear up taskData because it is a global object - if (data) { - data.nsTaskData = null; - } - task.target = target; - // task.capture = capture; - task.thisArg = (thisArg && new WeakRef(thisArg)) || undefined; - task.eventName = eventName; - existingTasks.push(task); - // return nativeAddListener.apply(target, args); - }; - const events: string[] = typeof originalArgs[0] === 'string' ? originalArgs[0].split(',') : []; - if (events.length > 0) { - Array.prototype.splice.call(originalArgs, 0, 1); - for (let i = 0; i < events.length; i++) { - addSingleEvent(originalTarget, [events[i].trim(), ...originalArgs]); - } - } else { - addSingleEvent(originalTarget, originalArgs); - } - } - ); - - const nativeOnce = api.patchMethod( - obj, - ONCE, - (delegate, delegateName, name) => - function (originalTarget, originalArgs) { - const addSingleEvent = function (target, args) { - const eventName = args[0]; - const callback = args[1]; - const taskData: NSTaskData = {}; - const thisArg = (args.length > 1 && args[2]) || undefined; - taskData.target = target; - taskData.eventName = eventName; - taskData.thisArg = thisArg; - let symbolEventNames = zoneSymbolEventNames[eventName]; - if (!symbolEventNames) { - prepareEventNames(eventName, eventNameToString); - symbolEventNames = zoneSymbolEventNames[eventName]; - } - const symbolEventName = symbolEventNames; - let existingTasks = target[symbolEventName]; - let isExisting = false; - let checkDuplicate = false; - if (existingTasks) { - // already have task registered - isExisting = true; - if (checkDuplicate) { - for (let i = 0; i < existingTasks.length; i++) { - if (compare(existingTasks[i], delegate, taskData.thisArg)) { - // same callback, same capture, same event name, just return - return; - } - } - } - } else { - existingTasks = target[symbolEventName] = []; - } - const schedule = (task: ExtendedTask) => { - task.ranOnce = false; - task.customCallback = function (...args) { - task.invoke.apply(this, args); - task.ranOnce = true; - task.target[REMOVE_EVENT_LISTENER](task.eventName, task.callback, task.thisArg ? task.thisArg.get() : undefined); - }; - const args2 = [taskData.eventName, task.invoke]; - if (taskData.thisArg) { - args2.push(taskData.thisArg); - } - delegate.apply(target, args2); - }; - const unschedule = (task: ExtendedTask) => { - if (task.ranOnce) { - return; - } - const args2 = [task.eventName, task.invoke]; - if (task.thisArg) { - args2.push(task.thisArg.get()); - } - nativeRemoveListener.apply(target, args2); - }; - const data: ExtendedTaskData = { - nsTaskData: taskData, - }; - const objName = obj.name || obj?.constructor?.name; - const task: ExtendedTask = Zone.current.scheduleEventTask(objName + ':' + (eventNameToString ? eventNameToString(eventName) : eventName), callback, data, schedule, unschedule); - // should clear taskData.target to avoid memory leak - // issue, https://github.com/angular/angular/issues/20442 - taskData.target = null; - - // need to clear up taskData because it is a global object - if (data) { - data.nsTaskData = null; - } - task.target = target; - // task.capture = capture; - task.thisArg = (thisArg && new WeakRef(thisArg)) || undefined; - task.eventName = eventName; - existingTasks.push(task); - }; - const events: string[] = originalArgs && Array.isArray(originalArgs) && typeof originalArgs[0] === 'string' ? originalArgs[0].split(',') : []; - if (events.length > 0) { - if (originalArgs && Array.isArray(originalArgs)) { - originalArgs.splice(0, 1); - } - for (let i = 0; i < events.length; i++) { - addSingleEvent(originalTarget, [events[i].trim(), ...originalArgs]); - } - } else { - addSingleEvent(originalTarget, originalArgs); - } - } - ); - - const nativeRemoveListener = api.patchMethod( - obj, - REMOVE_EVENT_LISTENER, - (delegate, delegateName, name) => - function (originalTarget, originalArgs) { - const removeSingleEvent = function (target, args) { - const eventName = args[0]; - const callback = args[1]; - const thisArg = (args.length > 1 && args[2]) || undefined; - const symbolEventNames = zoneSymbolEventNames[eventName]; - const symbolEventName = symbolEventNames; - const existingTasks: Task[] = symbolEventName && target[symbolEventName]; - const removeAll = !callback; // object.off(event); - if (existingTasks) { - if (removeAll) { - target[symbolEventName] = null; - } - for (let i = 0; i < existingTasks.length; i++) { - const existingTask = existingTasks[i]; - if (removeAll) { - (existingTask as any).isRemoved = true; - (existingTask as any).allRemoved = true; - existingTask.zone.cancelTask(existingTask); - continue; - } - if (compare(existingTask, callback, thisArg)) { - existingTasks.splice(i, 1); - // set isRemoved to data for faster invokeTask check - (existingTask as any).isRemoved = true; - if (existingTasks.length === 0) { - // all tasks for the eventName + capture have gone, - // remove globalZoneAwareCallback and remove the task cache from target - (existingTask as any).allRemoved = true; - target[symbolEventName] = null; - } - existingTask.zone.cancelTask(existingTask); - return; - } - } - } - return nativeRemoveListener.apply(target, args); - }; - const events: string[] = typeof originalArgs[0] === 'string' ? originalArgs[0].split(',') : []; - if (events.length > 0) { - Array.prototype.splice.call(originalArgs, 0, 1); - for (let i = 0; i < events.length; i++) { - removeSingleEvent(originalTarget, [events[i].trim(), ...originalArgs]); - } - } else { - removeSingleEvent(originalTarget, originalArgs); - } - } - ); - } - - let results: any[] = []; - for (let i = 0; i < apis.length; i++) { - results[i] = patchNativeScriptEventTargetMethods(apis[i], patchOptions); - } - - return results; -} - -const _global = global; - -const zoneSymbol = Zone.__symbol__; - -const originalInstanceKey = zoneSymbol('originalInstance'); - -function getAllPropertyNames(obj: unknown) { - const props = new Set(); - - do { - Object.getOwnPropertyNames(obj).forEach((prop) => { - props.add(prop); - }); - } while ((obj = Object.getPrototypeOf(obj)) && obj !== Object.prototype); - - return Array.from(props); -} - -// wrap some native API on `window` -export function patchClass(className: string, api: _ZonePrivate) { - const OriginalClass = _global[className]; - if (!OriginalClass) return; - // keep original class in global - _global[zoneSymbol(className)] = OriginalClass; - - _global[className] = function () { - const a = api.bindArguments(arguments, className); - switch (a.length) { - case 0: - this[originalInstanceKey] = new OriginalClass(); - break; - case 1: - this[originalInstanceKey] = new OriginalClass(a[0]); - break; - case 2: - this[originalInstanceKey] = new OriginalClass(a[0], a[1]); - break; - case 3: - this[originalInstanceKey] = new OriginalClass(a[0], a[1], a[2]); - break; - case 4: - this[originalInstanceKey] = new OriginalClass(a[0], a[1], a[2], a[3]); - break; - default: - throw new Error('Arg list too long.'); - } - }; - - // attach original delegate to patched function - api.attachOriginToPatched(_global[className], OriginalClass); - - const instance = new OriginalClass(function () {}); - - let prop; - for (prop of getAllProperties(instance)) { - // https://bugs.webkit.org/show_bug.cgi?id=44721 - if (className === 'XMLHttpRequest' && prop === 'responseBlob') continue; - (function (prop) { - if (typeof instance[prop] === 'function') { - _global[className].prototype[prop] = function () { - return this[originalInstanceKey][prop].apply(this[originalInstanceKey], arguments); - }; - } else { - api.ObjectDefineProperty(_global[className].prototype, prop, { - set: function (fn) { - if (typeof fn === 'function') { - this[originalInstanceKey][prop] = api.wrapWithCurrentZone(fn, className + '.' + prop); - // keep callback in wrapped function so we can - // use it in Function.prototype.toString to return - // the native one. - api.attachOriginToPatched(this[originalInstanceKey][prop], fn); - } else { - this[originalInstanceKey][prop] = fn; - } - }, - get: function () { - return this[originalInstanceKey][prop]; - }, - }); - } - })(prop); - } - - for (prop of Object.getOwnPropertyNames(OriginalClass)) { - if (prop !== 'prototype' && OriginalClass.hasOwnProperty(prop) && isWritable(_global[className], prop)) { - _global[className][prop] = OriginalClass[prop]; - } - } -} - -function getAllProperties(toCheck: any, lastProto = Object.prototype) { - const props = []; - let obj = toCheck; - do { - props.push(...Object.getOwnPropertyNames(obj)); - } while ((obj = Object.getPrototypeOf(obj)) && obj !== lastProto); - - return Array.from(new Set(props)); -} - -function isWritable(obj: T, key: keyof T) { - return Object.getOwnPropertyDescriptor(obj, key)?.writable ?? true; -} diff --git a/packages/zone-js/dist/xhr.ts b/packages/zone-js/dist/xhr.ts deleted file mode 100644 index 2e6c13ab..00000000 --- a/packages/zone-js/dist/xhr.ts +++ /dev/null @@ -1,203 +0,0 @@ -/* eslint-disable */ -Zone.__load_patch('nativescript_XHR', (global: any, Zone: ZoneType, api: _ZonePrivate) => { - const ADD_EVENT_LISTENER_STR = 'addEventListener'; - /** removeEventListener string const */ - const REMOVE_EVENT_LISTENER_STR = 'removeEventListener'; - /** zoneSymbol addEventListener */ - const ZONE_SYMBOL_ADD_EVENT_LISTENER = Zone.__symbol__(ADD_EVENT_LISTENER_STR); - /** zoneSymbol removeEventListener */ - const ZONE_SYMBOL_REMOVE_EVENT_LISTENER = Zone.__symbol__(REMOVE_EVENT_LISTENER_STR); - /** true string const */ - const TRUE_STR = 'true'; - /** false string const */ - const FALSE_STR = 'false'; - /** Zone symbol prefix string const. */ - const ZONE_SYMBOL_PREFIX = Zone.__symbol__(''); - const zoneSymbol = Zone.__symbol__; - function scheduleMacroTaskWithCurrentZone(source: string, callback: Function, data?: TaskData, customSchedule?: (task: Task) => void, customCancel?: (task: Task) => void): MacroTask { - return Zone.current.scheduleMacroTask(source, callback, data, customSchedule, customCancel); - } - // Treat XMLHttpRequest as a macrotask. - patchXHR(global); - - const XHR_TASK = zoneSymbol('xhrTask'); - const XHR_SYNC = zoneSymbol('xhrSync'); - const XHR_LISTENER = zoneSymbol('xhrListener'); - const XHR_SCHEDULED = zoneSymbol('xhrScheduled'); - const XHR_URL = zoneSymbol('xhrURL'); - const XHR_ERROR_BEFORE_SCHEDULED = zoneSymbol('xhrErrorBeforeScheduled'); - - interface XHROptions extends TaskData { - target: any; - url: string; - args: any[]; - aborted: boolean; - } - - function patchXHR(window: any) { - const XMLHttpRequest = window['XMLHttpRequest']; - if (!XMLHttpRequest) { - // XMLHttpRequest is not available in service worker - return; - } - const XMLHttpRequestPrototype: any = XMLHttpRequest.prototype; - - function findPendingTask(target: any) { - return target[XHR_TASK]; - } - - let oriAddListener = XMLHttpRequestPrototype[ZONE_SYMBOL_ADD_EVENT_LISTENER]; - let oriRemoveListener = XMLHttpRequestPrototype[ZONE_SYMBOL_REMOVE_EVENT_LISTENER]; - if (!oriAddListener) { - const XMLHttpRequestEventTarget = window['XMLHttpRequestEventTarget']; - if (XMLHttpRequestEventTarget) { - const XMLHttpRequestEventTargetPrototype = XMLHttpRequestEventTarget.prototype; - oriAddListener = XMLHttpRequestEventTargetPrototype[ZONE_SYMBOL_ADD_EVENT_LISTENER]; - oriRemoveListener = XMLHttpRequestEventTargetPrototype[ZONE_SYMBOL_REMOVE_EVENT_LISTENER]; - } - } - - const READY_STATE_CHANGE = 'readystatechange'; - const SCHEDULED = 'scheduled'; - - function scheduleTask(task: Task) { - const data = task.data; - const target = data.target; - target[XHR_SCHEDULED] = false; - target[XHR_ERROR_BEFORE_SCHEDULED] = false; - // remove existing event listener - const listener = target[XHR_LISTENER]; - if (!oriAddListener) { - oriAddListener = target[ZONE_SYMBOL_ADD_EVENT_LISTENER]; - oriRemoveListener = target[ZONE_SYMBOL_REMOVE_EVENT_LISTENER]; - } - - if (listener) { - oriRemoveListener.call(target, READY_STATE_CHANGE, listener); - } - const newListener = (target[XHR_LISTENER] = () => { - if (target.readyState === target.DONE) { - // sometimes on some browsers XMLHttpRequest will fire onreadystatechange with - // readyState=4 multiple times, so we need to check task state here - if (!data.aborted && target[XHR_SCHEDULED] && task.state === SCHEDULED) { - // check whether the xhr has registered onload listener - // if that is the case, the task should invoke after all - // onload listeners finish. - // Also if the request failed without response (status = 0), the load event handler - // will not be triggered, in that case, we should also invoke the placeholder callback - // to close the XMLHttpRequest::send macroTask. - // https://github.com/angular/angular/issues/38795 - const loadTasks = target[Zone.__symbol__('loadfalse')]; - if (target.status !== 0 && loadTasks && loadTasks.length > 0) { - const oriInvoke = task.invoke; - task.invoke = function () { - // need to load the tasks again, because in other - // load listener, they may remove themselves - const loadTasks = target[Zone.__symbol__('loadfalse')]; - for (let i = 0; i < loadTasks.length; i++) { - if (loadTasks[i] === task) { - loadTasks.splice(i, 1); - } - } - if (!data.aborted && task.state === SCHEDULED) { - oriInvoke.call(task); - } - }; - loadTasks.push(task); - } else { - task.invoke(); - } - } else if (!data.aborted && target[XHR_SCHEDULED] === false) { - // error occurs when xhr.send() - target[XHR_ERROR_BEFORE_SCHEDULED] = true; - } - } - }); - oriAddListener.call(target, READY_STATE_CHANGE, newListener); - - const storedTask: Task = target[XHR_TASK]; - if (!storedTask) { - target[XHR_TASK] = task; - } - sendNative!.apply(target, data.args); - target[XHR_SCHEDULED] = true; - return task; - } - - function placeholderCallback() {} - - function clearTask(task: Task) { - const data = task.data; - // Note - ideally, we would call data.target.removeEventListener here, but it's too late - // to prevent it from firing. So instead, we store info for the event listener. - data.aborted = true; - return abortNative!.apply(data.target, data.args); - } - - const openNative = api.patchMethod( - XMLHttpRequestPrototype, - 'open', - () => - function (self: any, args: any[]) { - self[XHR_SYNC] = args[2] == false; - self[XHR_URL] = args[1]; - return openNative!.apply(self, args); - } - ); - - const XMLHTTPREQUEST_SOURCE = 'XMLHttpRequest.send'; - const fetchTaskAborting = zoneSymbol('fetchTaskAborting'); - const fetchTaskScheduling = zoneSymbol('fetchTaskScheduling'); - const sendNative: Function | null = api.patchMethod( - XMLHttpRequestPrototype, - 'send', - () => - function (self: any, args: any[]) { - if ((Zone.current as any)[fetchTaskScheduling] === true) { - // a fetch is scheduling, so we are using xhr to polyfill fetch - // and because we already schedule macroTask for fetch, we should - // not schedule a macroTask for xhr again - return sendNative!.apply(self, args); - } - if (self[XHR_SYNC]) { - // if the XHR is sync there is no task to schedule, just execute the code. - return sendNative!.apply(self, args); - } else { - const options: XHROptions = { target: self, url: self[XHR_URL], isPeriodic: false, args: args, aborted: false }; - const task = scheduleMacroTaskWithCurrentZone(XMLHTTPREQUEST_SOURCE, placeholderCallback, options, scheduleTask, clearTask); - if (self && self[XHR_ERROR_BEFORE_SCHEDULED] === true && !options.aborted && task.state === SCHEDULED) { - // xhr request throw error when send - // we should invoke task instead of leaving a scheduled - // pending macroTask - task.invoke(); - } - } - } - ); - - const abortNative = api.patchMethod( - XMLHttpRequestPrototype, - 'abort', - () => - function (self: any, args: any[]) { - const task: Task = findPendingTask(self); - if (task && typeof task.type == 'string') { - // If the XHR has already completed, do nothing. - // If the XHR has already been aborted, do nothing. - // Fix #569, call abort multiple times before done will cause - // macroTask task count be negative number - if (task.cancelFn == null || (task.data && (task.data).aborted)) { - return; - } - task.zone.cancelTask(task); - } else if ((Zone.current as any)[fetchTaskAborting] === true) { - // the abort is called from fetch polyfill, we need to call native abort of XHR. - return abortNative!.apply(self, args); - } - // Otherwise, we are trying to abort an XHR which has not yet been sent, so there is no - // task - // to cancel. Do nothing. - } - ); - } -}); From 61a11e3ec13597b6a35033ef93b8d631b4b79648 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 11 Apr 2026 14:53:23 -0700 Subject: [PATCH 03/19] chore: re-add zone files --- packages/zone-js/dist/connectivity.ts | 15 + packages/zone-js/dist/core.ts | 63 +++ packages/zone-js/dist/events.ts | 69 +++ packages/zone-js/dist/index.ts | 5 + packages/zone-js/dist/nativescript-globals.ts | 22 + packages/zone-js/dist/pre-zone-polyfills.ts | 12 + packages/zone-js/dist/trace-error.ts | 9 + packages/zone-js/dist/utils.ts | 461 ++++++++++++++++++ packages/zone-js/dist/xhr.ts | 221 +++++++++ 9 files changed, 877 insertions(+) create mode 100644 packages/zone-js/dist/connectivity.ts create mode 100644 packages/zone-js/dist/core.ts create mode 100644 packages/zone-js/dist/events.ts create mode 100644 packages/zone-js/dist/index.ts create mode 100644 packages/zone-js/dist/nativescript-globals.ts create mode 100644 packages/zone-js/dist/pre-zone-polyfills.ts create mode 100644 packages/zone-js/dist/trace-error.ts create mode 100644 packages/zone-js/dist/utils.ts create mode 100644 packages/zone-js/dist/xhr.ts diff --git a/packages/zone-js/dist/connectivity.ts b/packages/zone-js/dist/connectivity.ts new file mode 100644 index 00000000..4eb078c6 --- /dev/null +++ b/packages/zone-js/dist/connectivity.ts @@ -0,0 +1,15 @@ +/* eslint-disable */ +import './core'; +import { Connectivity } from '@nativescript/core'; + +Zone.__load_patch('nativescript_connectivity', (global, zone, api) => { + api.patchMethod( + Connectivity, + 'startMonitoring', + (delegate, delegateName, name) => + function (self, args) { + const callback = args[0]; + return delegate.apply(self, [Zone.current.wrap(callback, 'NS Connectivity patch')]); + }, + ); +}); diff --git a/packages/zone-js/dist/core.ts b/packages/zone-js/dist/core.ts new file mode 100644 index 00000000..c590e8e3 --- /dev/null +++ b/packages/zone-js/dist/core.ts @@ -0,0 +1,63 @@ +/* eslint-disable */ +import { patchClass, patchNativeScriptEventTarget } from './utils'; + +function isPropertyWritable(propertyDesc: any) { + if (!propertyDesc) { + return true; + } + + if (propertyDesc.writable === false) { + return false; + } + + return !(typeof propertyDesc.get === 'function' && typeof propertyDesc.set === 'undefined'); +} + +Zone.__load_patch('nativescript_patchMethod', (global, Zone, api) => { + api.patchMethod = function patchMethod( + target: any, + name: string, + patchFn: (delegate: Function, delegateName: string, name: string) => (self: any, args: any[]) => any, + ): Function | null { + let proto = target; + while (proto && !proto.hasOwnProperty(name)) { + proto = Object.getPrototypeOf(proto); + } + if (!proto && target[name]) { + // somehow we did not find it, but we can see it. This happens on IE for Window properties. + proto = target; + } + + const delegateName = Zone.__symbol__(name); + let delegate: Function | null = null; + if (proto && !proto.hasOwnProperty(delegateName)) { + delegate = proto[delegateName] = proto[name]; + // check whether proto[name] is writable + // some property is readonly in safari, such as HtmlCanvasElement.prototype.toBlob + const desc = proto && api.ObjectGetOwnPropertyDescriptor(proto, name); + if (isPropertyWritable(desc)) { + const patchDelegate = patchFn(delegate!, delegateName, name); + proto[name] = function () { + return patchDelegate(this, arguments as any); + }; + api.attachOriginToPatched(proto[name], delegate); + // if (shouldCopySymbolProperties) { + // copySymbolProperties(delegate, proto[name]); + // } + } + } + return delegate; + }; +}); + +Zone.__load_patch('nativescript_event_target_api', (g, z, api: any) => { + api.patchNativeScriptEventTarget = patchNativeScriptEventTarget; +}); + +Zone.__load_patch('nativescript_patch_class_api', (g, z, api) => { + api.patchClass = (className: string) => patchClass(className, api); +}); + +// Initialize zone microtask queue on main thread +// TODO: dive into the ios runtime (PromiseProxy) and find a better solution +Promise.resolve().then(() => {}); diff --git a/packages/zone-js/dist/events.ts b/packages/zone-js/dist/events.ts new file mode 100644 index 00000000..b51d20a7 --- /dev/null +++ b/packages/zone-js/dist/events.ts @@ -0,0 +1,69 @@ +/* eslint-disable */ +import './core'; +import { Observable, View, Utils } from '@nativescript/core'; + +Zone.__load_patch('nativescript_observable_events', (g, z, api: any) => { + api.patchNativeScriptEventTarget(g, api, [Observable, Observable.prototype, View, View.prototype]); +}); + +Zone.__load_patch('nativescript_xhr_events', (g, z, api: any) => { + api.patchNativeScriptEventTarget(g, api, [XMLHttpRequest.prototype]); +}); + +// We're patching the Utils object instead of the actual js module +Zone.__load_patch('nativescript_mainThreadify', (global, zone, api) => { + api.patchMethod( + Utils, + 'mainThreadify', + (delegate, delegateName, name) => + function (self, args) { + const callback = args[0]; + return delegate.apply(self, [Zone.current.wrap(callback, 'NS mainThreadify patch')]); + }, + ); +}); + +Zone.__load_patch('nativescript_executeOnMainThread', (global, zone, api) => { + api.patchMethod( + Utils, + 'executeOnMainThread', + (delegate, delegateName, name) => + function (self, args) { + const callback = args[0]; + return delegate.apply(self, [Zone.current.wrap(callback, 'NS executeOnMainThread patch')]); + }, + ); +}); + +Zone.__load_patch('nativescript_dispatchToMainThread', (global, zone, api) => { + api.patchMethod( + Utils, + 'dispatchToMainThread', + (delegate, delegateName, name) => + function (self, args) { + const callback = args[0]; + return delegate.apply(self, [Zone.current.wrap(callback, 'NS dispatchToMainThread patch')]); + }, + ); +}); + +Zone.__load_patch('nativescript_showModal', (global, zone, api) => { + api.patchMethod( + View.prototype, + 'showModal', + (delegate, delegateName, name) => + function (self, args) { + if (args.length === 2) { + const options = args[1]; + if (options.closeCallback) { + options.closeCallback = Zone.current.wrap(options.closeCallback, 'NS showModal patch'); + } + } else if (args.length > 3) { + args[3] = Zone.current.wrap(args[3], 'NS showModal patch'); + } + return delegate.apply(self, args); + }, + ); +}); + +//! queueMacroTask should never be patched! We should consider it as a low level API to queue macroTasks which will be patched separately by other patches. diff --git a/packages/zone-js/dist/index.ts b/packages/zone-js/dist/index.ts new file mode 100644 index 00000000..330a1a51 --- /dev/null +++ b/packages/zone-js/dist/index.ts @@ -0,0 +1,5 @@ +import './core'; +import './nativescript-globals'; +import './events'; +import './xhr'; +import './connectivity'; diff --git a/packages/zone-js/dist/nativescript-globals.ts b/packages/zone-js/dist/nativescript-globals.ts new file mode 100644 index 00000000..13efd5e2 --- /dev/null +++ b/packages/zone-js/dist/nativescript-globals.ts @@ -0,0 +1,22 @@ +// Zone.__load_patch('nativescript_MutationObserver', (global: any, Zone: ZoneType, api: _ZonePrivate) => { +// api.patchClass('MutationObserver'); +// api.patchClass('WebKitMutationObserver'); +// }); + +// Zone.__load_patch('nativescript_IntersectionObserver', (global: any, Zone: ZoneType, api: _ZonePrivate) => { +// api.patchClass('IntersectionObserver'); +// }); + +/* eslint-disable @typescript-eslint/no-explicit-any */ +Zone.__load_patch('nativescript_FileReader', (global: any, Zone: ZoneType, api: _ZonePrivate) => { + const reader = global['FileReader']; + if (reader) { + reader.prototype.onload = reader.prototype.onload || null; + reader.prototype.onerror = reader.prototype.onerror || null; + reader.prototype.onabort = reader.prototype.onabort || null; + reader.prototype.onloadend = reader.prototype.onloadend || null; + reader.prototype.onloadstart = reader.prototype.onloadstart || null; + reader.prototype.onprogress = reader.prototype.onprogress || null; + } + api.patchClass('FileReader'); +}); diff --git a/packages/zone-js/dist/pre-zone-polyfills.ts b/packages/zone-js/dist/pre-zone-polyfills.ts new file mode 100644 index 00000000..54999d1a --- /dev/null +++ b/packages/zone-js/dist/pre-zone-polyfills.ts @@ -0,0 +1,12 @@ +export const disabledPatches = [ + 'legacy', + 'EventTarget', + 'XHR', + 'MutationObserver', + 'IntersectionObserver', + 'FileReader', +]; + +for (const patch of disabledPatches) { + global[`__Zone_disable_${patch}`] = true; +} diff --git a/packages/zone-js/dist/trace-error.ts b/packages/zone-js/dist/trace-error.ts new file mode 100644 index 00000000..c4a1fb21 --- /dev/null +++ b/packages/zone-js/dist/trace-error.ts @@ -0,0 +1,9 @@ +/* eslint-disable */ +import { Trace } from '@nativescript/core'; + +Zone.__load_patch('nativescript_zone_to_trace_error', (global, zone, api) => { + zone[zone.__symbol__('unhandledPromiseRejectionHandler')] = (e) => { + Trace.error(e); + }; + zone[zone.__symbol__('ignoreConsoleErrorUncaughtError')] = true; +}); diff --git a/packages/zone-js/dist/utils.ts b/packages/zone-js/dist/utils.ts new file mode 100644 index 00000000..d477c105 --- /dev/null +++ b/packages/zone-js/dist/utils.ts @@ -0,0 +1,461 @@ +/* eslint-disable */ +const ZONE_SYMBOL_PREFIX = Zone.__symbol__(''); +const zoneSymbolEventNames: any = {}; +const ADD_EVENT_LISTENER_STR = 'addEventListener'; +const REMOVE_EVENT_LISTENER_STR = 'removeEventListener'; +function prepareEventNames(eventName: string, eventNameToString?: (eventName: string) => string) { + // const falseEventName = (eventNameToString ? eventNameToString(eventName) : eventName) + FALSE_STR; + // const trueEventName = (eventNameToString ? eventNameToString(eventName) : eventName) + TRUE_STR; + // const symbol = ZONE_SYMBOL_PREFIX + falseEventName; + // const symbolCapture = ZONE_SYMBOL_PREFIX + trueEventName; + // zoneSymbolEventNames[eventName] = {}; + // zoneSymbolEventNames[eventName][FALSE_STR] = symbol; + // zoneSymbolEventNames[eventName][TRUE_STR] = symbolCapture; + const symbol = ZONE_SYMBOL_PREFIX + (eventNameToString ? eventNameToString(eventName) : eventName) + 'false'; + zoneSymbolEventNames[eventName] = symbol; +} +interface NSTaskData { + thisArg?: any; + eventName?: string; + target?: any; + actualDelegate?: any; +} +interface ExtendedTaskData extends TaskData { + nsTaskData?: NSTaskData; +} + +interface ExtendedTask extends Task { + thisArg?: WeakRef; + eventName?: string; + target?: any; + customCallback?: any; + ranOnce?: boolean; +} +export interface PatchEventTargetOptions { + // validateHandler + vh?: (nativeDelegate: any, delegate: any, target: any, args: any) => boolean; + // addEventListener function name + add?: string; + // removeEventListener function name + rm?: string; + // once function name + once?: string; + // listeners function name + listeners?: string; + // removeAllListeners function name + rmAll?: string; + // check duplicate flag when addEventListener + chkDup?: boolean; + // return target flag when addEventListener + rt?: boolean; + // event compare handler + diff?: (task: any, delegate: any) => boolean; + // support passive or not + supportPassive?: boolean; + // get string from eventName (in nodejs, eventName maybe Symbol) + eventNameToString?: (eventName: any) => string; + // transfer eventName + transferEventName?: (eventName: string) => string; +} + +export function patchNativeScriptEventTarget( + global: any, + api: _ZonePrivate, + apis?: any[], + patchOptions?: PatchEventTargetOptions, +) { + const ADD_EVENT_LISTENER = (patchOptions && patchOptions.add) || ADD_EVENT_LISTENER_STR; + const REMOVE_EVENT_LISTENER = (patchOptions && patchOptions.rm) || REMOVE_EVENT_LISTENER_STR; + const ONCE = (patchOptions && patchOptions.once) || 'once'; + + const zoneSymbolAddEventListener = Zone.__symbol__(ADD_EVENT_LISTENER); + + const ADD_EVENT_LISTENER_SOURCE = '.' + ADD_EVENT_LISTENER + ':'; + + function patchNativeScriptEventTargetMethods(obj, patchOptions) { + if (!obj) { + return false; + } + const eventNameToString = patchOptions && patchOptions.eventNameToString; + // let proto = obj; + // while (proto && !proto.hasOwnProperty(ADD_EVENT_LISTENER)) { + // proto = Object.getPrototypeOf(proto); + // } + // if (!proto && obj[ADD_EVENT_LISTENER]) { + // // somehow we did not find it, but we can see it. This happens on IE for Window properties. + // proto = obj; + // } + + // if (!proto) { + // return false; + // } + // if (proto[zoneSymbolAddEventListener]) { + // return false; + // } + function compare(task: ExtendedTask, delegate: any, thisArg?: any) { + const taskThis = task.thisArg ? task.thisArg.get() : undefined; + if (!thisArg) { + thisArg = undefined; // keep consistent + } + return task.callback === delegate && taskThis === thisArg; + } + + const nativeAddListener = api.patchMethod( + obj, + ADD_EVENT_LISTENER, + (delegate, delegateName, name) => + function (originalTarget, originalArgs) { + const addSingleEvent = function (target, args) { + const eventName = args[0]; + const callback = args[1]; + const taskData: NSTaskData = {}; + const thisArg = (args.length > 1 && args[2]) || undefined; + taskData.target = target; + taskData.eventName = eventName; + taskData.thisArg = thisArg; + let symbolEventNames = zoneSymbolEventNames[eventName]; + if (!symbolEventNames) { + prepareEventNames(eventName, eventNameToString); + symbolEventNames = zoneSymbolEventNames[eventName]; + } + const symbolEventName = symbolEventNames; + let existingTasks = target[symbolEventName]; + let isExisting = false; + let checkDuplicate = false; + if (existingTasks) { + // already have task registered + isExisting = true; + if (checkDuplicate) { + for (let i = 0; i < existingTasks.length; i++) { + if (compare(existingTasks[i], delegate, taskData.thisArg)) { + // same callback, same capture, same event name, just return + return; + } + } + } + } else { + existingTasks = target[symbolEventName] = []; + } + const schedule = (task: Task) => { + const args2 = [taskData.eventName, task.invoke]; + if (taskData.thisArg) { + args2.push(taskData.thisArg); + } + delegate.apply(target, args2); + }; + const unschedule = (task: ExtendedTask) => { + const args2 = [task.eventName, task.invoke]; + if (task.thisArg) { + args2.push(task.thisArg.get()); + } + nativeRemoveListener.apply(target, args2); + }; + const data: ExtendedTaskData = { + nsTaskData: taskData, + }; + const objName = obj.name || obj?.constructor?.name; + const task: ExtendedTask = Zone.current.scheduleEventTask( + objName + ':' + (eventNameToString ? eventNameToString(eventName) : eventName), + callback, + data, + schedule, + unschedule, + ); + // should clear taskData.target to avoid memory leak + // issue, https://github.com/angular/angular/issues/20442 + taskData.target = null; + + // need to clear up taskData because it is a global object + if (data) { + data.nsTaskData = null; + } + task.target = target; + // task.capture = capture; + task.thisArg = (thisArg && new WeakRef(thisArg)) || undefined; + task.eventName = eventName; + existingTasks.push(task); + // return nativeAddListener.apply(target, args); + }; + const events: string[] = typeof originalArgs[0] === 'string' ? originalArgs[0].split(',') : []; + if (events.length > 0) { + Array.prototype.splice.call(originalArgs, 0, 1); + for (let i = 0; i < events.length; i++) { + addSingleEvent(originalTarget, [events[i].trim(), ...originalArgs]); + } + } else { + addSingleEvent(originalTarget, originalArgs); + } + }, + ); + + const nativeOnce = api.patchMethod( + obj, + ONCE, + (delegate, delegateName, name) => + function (originalTarget, originalArgs) { + const addSingleEvent = function (target, args) { + const eventName = args[0]; + const callback = args[1]; + const taskData: NSTaskData = {}; + const thisArg = (args.length > 1 && args[2]) || undefined; + taskData.target = target; + taskData.eventName = eventName; + taskData.thisArg = thisArg; + let symbolEventNames = zoneSymbolEventNames[eventName]; + if (!symbolEventNames) { + prepareEventNames(eventName, eventNameToString); + symbolEventNames = zoneSymbolEventNames[eventName]; + } + const symbolEventName = symbolEventNames; + let existingTasks = target[symbolEventName]; + let isExisting = false; + let checkDuplicate = false; + if (existingTasks) { + // already have task registered + isExisting = true; + if (checkDuplicate) { + for (let i = 0; i < existingTasks.length; i++) { + if (compare(existingTasks[i], delegate, taskData.thisArg)) { + // same callback, same capture, same event name, just return + return; + } + } + } + } else { + existingTasks = target[symbolEventName] = []; + } + const schedule = (task: ExtendedTask) => { + task.ranOnce = false; + task.customCallback = function (...args) { + task.invoke.apply(this, args); + task.ranOnce = true; + task.target[REMOVE_EVENT_LISTENER]( + task.eventName, + task.callback, + task.thisArg ? task.thisArg.get() : undefined, + ); + }; + const args2 = [taskData.eventName, task.invoke]; + if (taskData.thisArg) { + args2.push(taskData.thisArg); + } + delegate.apply(target, args2); + }; + const unschedule = (task: ExtendedTask) => { + if (task.ranOnce) { + return; + } + const args2 = [task.eventName, task.invoke]; + if (task.thisArg) { + args2.push(task.thisArg.get()); + } + nativeRemoveListener.apply(target, args2); + }; + const data: ExtendedTaskData = { + nsTaskData: taskData, + }; + const objName = obj.name || obj?.constructor?.name; + const task: ExtendedTask = Zone.current.scheduleEventTask( + objName + ':' + (eventNameToString ? eventNameToString(eventName) : eventName), + callback, + data, + schedule, + unschedule, + ); + // should clear taskData.target to avoid memory leak + // issue, https://github.com/angular/angular/issues/20442 + taskData.target = null; + + // need to clear up taskData because it is a global object + if (data) { + data.nsTaskData = null; + } + task.target = target; + // task.capture = capture; + task.thisArg = (thisArg && new WeakRef(thisArg)) || undefined; + task.eventName = eventName; + existingTasks.push(task); + }; + const events: string[] = + originalArgs && Array.isArray(originalArgs) && typeof originalArgs[0] === 'string' + ? originalArgs[0].split(',') + : []; + if (events.length > 0) { + if (originalArgs && Array.isArray(originalArgs)) { + originalArgs.splice(0, 1); + } + for (let i = 0; i < events.length; i++) { + addSingleEvent(originalTarget, [events[i].trim(), ...originalArgs]); + } + } else { + addSingleEvent(originalTarget, originalArgs); + } + }, + ); + + const nativeRemoveListener = api.patchMethod( + obj, + REMOVE_EVENT_LISTENER, + (delegate, delegateName, name) => + function (originalTarget, originalArgs) { + const removeSingleEvent = function (target, args) { + const eventName = args[0]; + const callback = args[1]; + const thisArg = (args.length > 1 && args[2]) || undefined; + const symbolEventNames = zoneSymbolEventNames[eventName]; + const symbolEventName = symbolEventNames; + const existingTasks: Task[] = symbolEventName && target[symbolEventName]; + const removeAll = !callback; // object.off(event); + if (existingTasks) { + if (removeAll) { + target[symbolEventName] = null; + } + for (let i = 0; i < existingTasks.length; i++) { + const existingTask = existingTasks[i]; + if (removeAll) { + (existingTask as any).isRemoved = true; + (existingTask as any).allRemoved = true; + existingTask.zone.cancelTask(existingTask); + continue; + } + if (compare(existingTask, callback, thisArg)) { + existingTasks.splice(i, 1); + // set isRemoved to data for faster invokeTask check + (existingTask as any).isRemoved = true; + if (existingTasks.length === 0) { + // all tasks for the eventName + capture have gone, + // remove globalZoneAwareCallback and remove the task cache from target + (existingTask as any).allRemoved = true; + target[symbolEventName] = null; + } + existingTask.zone.cancelTask(existingTask); + return; + } + } + } + return nativeRemoveListener.apply(target, args); + }; + const events: string[] = typeof originalArgs[0] === 'string' ? originalArgs[0].split(',') : []; + if (events.length > 0) { + Array.prototype.splice.call(originalArgs, 0, 1); + for (let i = 0; i < events.length; i++) { + removeSingleEvent(originalTarget, [events[i].trim(), ...originalArgs]); + } + } else { + removeSingleEvent(originalTarget, originalArgs); + } + }, + ); + } + + let results: any[] = []; + for (let i = 0; i < apis.length; i++) { + results[i] = patchNativeScriptEventTargetMethods(apis[i], patchOptions); + } + + return results; +} + +const _global = global; + +const zoneSymbol = Zone.__symbol__; + +const originalInstanceKey = zoneSymbol('originalInstance'); + +function getAllPropertyNames(obj: unknown) { + const props = new Set(); + + do { + Object.getOwnPropertyNames(obj).forEach((prop) => { + props.add(prop); + }); + } while ((obj = Object.getPrototypeOf(obj)) && obj !== Object.prototype); + + return Array.from(props); +} + +// wrap some native API on `window` +export function patchClass(className: string, api: _ZonePrivate) { + const OriginalClass = _global[className]; + if (!OriginalClass) return; + // keep original class in global + _global[zoneSymbol(className)] = OriginalClass; + + _global[className] = function () { + const a = api.bindArguments(arguments, className); + switch (a.length) { + case 0: + this[originalInstanceKey] = new OriginalClass(); + break; + case 1: + this[originalInstanceKey] = new OriginalClass(a[0]); + break; + case 2: + this[originalInstanceKey] = new OriginalClass(a[0], a[1]); + break; + case 3: + this[originalInstanceKey] = new OriginalClass(a[0], a[1], a[2]); + break; + case 4: + this[originalInstanceKey] = new OriginalClass(a[0], a[1], a[2], a[3]); + break; + default: + throw new Error('Arg list too long.'); + } + }; + + // attach original delegate to patched function + api.attachOriginToPatched(_global[className], OriginalClass); + + const instance = new OriginalClass(function () {}); + + let prop; + for (prop of getAllProperties(instance)) { + // https://bugs.webkit.org/show_bug.cgi?id=44721 + if (className === 'XMLHttpRequest' && prop === 'responseBlob') continue; + (function (prop) { + if (typeof instance[prop] === 'function') { + _global[className].prototype[prop] = function () { + return this[originalInstanceKey][prop].apply(this[originalInstanceKey], arguments); + }; + } else { + api.ObjectDefineProperty(_global[className].prototype, prop, { + set: function (fn) { + if (typeof fn === 'function') { + this[originalInstanceKey][prop] = api.wrapWithCurrentZone(fn, className + '.' + prop); + // keep callback in wrapped function so we can + // use it in Function.prototype.toString to return + // the native one. + api.attachOriginToPatched(this[originalInstanceKey][prop], fn); + } else { + this[originalInstanceKey][prop] = fn; + } + }, + get: function () { + return this[originalInstanceKey][prop]; + }, + }); + } + })(prop); + } + + for (prop of Object.getOwnPropertyNames(OriginalClass)) { + if (prop !== 'prototype' && OriginalClass.hasOwnProperty(prop) && isWritable(_global[className], prop)) { + _global[className][prop] = OriginalClass[prop]; + } + } +} + +function getAllProperties(toCheck: any, lastProto = Object.prototype) { + const props = []; + let obj = toCheck; + do { + props.push(...Object.getOwnPropertyNames(obj)); + } while ((obj = Object.getPrototypeOf(obj)) && obj !== lastProto); + + return Array.from(new Set(props)); +} + +function isWritable(obj: T, key: keyof T) { + return Object.getOwnPropertyDescriptor(obj, key)?.writable ?? true; +} diff --git a/packages/zone-js/dist/xhr.ts b/packages/zone-js/dist/xhr.ts new file mode 100644 index 00000000..29bdd731 --- /dev/null +++ b/packages/zone-js/dist/xhr.ts @@ -0,0 +1,221 @@ +/* eslint-disable */ +Zone.__load_patch('nativescript_XHR', (global: any, Zone: ZoneType, api: _ZonePrivate) => { + const ADD_EVENT_LISTENER_STR = 'addEventListener'; + /** removeEventListener string const */ + const REMOVE_EVENT_LISTENER_STR = 'removeEventListener'; + /** zoneSymbol addEventListener */ + const ZONE_SYMBOL_ADD_EVENT_LISTENER = Zone.__symbol__(ADD_EVENT_LISTENER_STR); + /** zoneSymbol removeEventListener */ + const ZONE_SYMBOL_REMOVE_EVENT_LISTENER = Zone.__symbol__(REMOVE_EVENT_LISTENER_STR); + /** true string const */ + const TRUE_STR = 'true'; + /** false string const */ + const FALSE_STR = 'false'; + /** Zone symbol prefix string const. */ + const ZONE_SYMBOL_PREFIX = Zone.__symbol__(''); + const zoneSymbol = Zone.__symbol__; + function scheduleMacroTaskWithCurrentZone( + source: string, + callback: Function, + data?: TaskData, + customSchedule?: (task: Task) => void, + customCancel?: (task: Task) => void, + ): MacroTask { + return Zone.current.scheduleMacroTask(source, callback, data, customSchedule, customCancel); + } + // Treat XMLHttpRequest as a macrotask. + patchXHR(global); + + const XHR_TASK = zoneSymbol('xhrTask'); + const XHR_SYNC = zoneSymbol('xhrSync'); + const XHR_LISTENER = zoneSymbol('xhrListener'); + const XHR_SCHEDULED = zoneSymbol('xhrScheduled'); + const XHR_URL = zoneSymbol('xhrURL'); + const XHR_ERROR_BEFORE_SCHEDULED = zoneSymbol('xhrErrorBeforeScheduled'); + + interface XHROptions extends TaskData { + target: any; + url: string; + args: any[]; + aborted: boolean; + } + + function patchXHR(window: any) { + const XMLHttpRequest = window['XMLHttpRequest']; + if (!XMLHttpRequest) { + // XMLHttpRequest is not available in service worker + return; + } + const XMLHttpRequestPrototype: any = XMLHttpRequest.prototype; + + function findPendingTask(target: any) { + return target[XHR_TASK]; + } + + let oriAddListener = XMLHttpRequestPrototype[ZONE_SYMBOL_ADD_EVENT_LISTENER]; + let oriRemoveListener = XMLHttpRequestPrototype[ZONE_SYMBOL_REMOVE_EVENT_LISTENER]; + if (!oriAddListener) { + const XMLHttpRequestEventTarget = window['XMLHttpRequestEventTarget']; + if (XMLHttpRequestEventTarget) { + const XMLHttpRequestEventTargetPrototype = XMLHttpRequestEventTarget.prototype; + oriAddListener = XMLHttpRequestEventTargetPrototype[ZONE_SYMBOL_ADD_EVENT_LISTENER]; + oriRemoveListener = XMLHttpRequestEventTargetPrototype[ZONE_SYMBOL_REMOVE_EVENT_LISTENER]; + } + } + + const READY_STATE_CHANGE = 'readystatechange'; + const SCHEDULED = 'scheduled'; + + function scheduleTask(task: Task) { + const data = task.data; + const target = data.target; + target[XHR_SCHEDULED] = false; + target[XHR_ERROR_BEFORE_SCHEDULED] = false; + // remove existing event listener + const listener = target[XHR_LISTENER]; + if (!oriAddListener) { + oriAddListener = target[ZONE_SYMBOL_ADD_EVENT_LISTENER]; + oriRemoveListener = target[ZONE_SYMBOL_REMOVE_EVENT_LISTENER]; + } + + if (listener) { + oriRemoveListener.call(target, READY_STATE_CHANGE, listener); + } + const newListener = (target[XHR_LISTENER] = () => { + if (target.readyState === target.DONE) { + // sometimes on some browsers XMLHttpRequest will fire onreadystatechange with + // readyState=4 multiple times, so we need to check task state here + if (!data.aborted && target[XHR_SCHEDULED] && task.state === SCHEDULED) { + // check whether the xhr has registered onload listener + // if that is the case, the task should invoke after all + // onload listeners finish. + // Also if the request failed without response (status = 0), the load event handler + // will not be triggered, in that case, we should also invoke the placeholder callback + // to close the XMLHttpRequest::send macroTask. + // https://github.com/angular/angular/issues/38795 + const loadTasks = target[Zone.__symbol__('loadfalse')]; + if (target.status !== 0 && loadTasks && loadTasks.length > 0) { + const oriInvoke = task.invoke; + task.invoke = function () { + // need to load the tasks again, because in other + // load listener, they may remove themselves + const loadTasks = target[Zone.__symbol__('loadfalse')]; + for (let i = 0; i < loadTasks.length; i++) { + if (loadTasks[i] === task) { + loadTasks.splice(i, 1); + } + } + if (!data.aborted && task.state === SCHEDULED) { + oriInvoke.call(task); + } + }; + loadTasks.push(task); + } else { + task.invoke(); + } + } else if (!data.aborted && target[XHR_SCHEDULED] === false) { + // error occurs when xhr.send() + target[XHR_ERROR_BEFORE_SCHEDULED] = true; + } + } + }); + oriAddListener.call(target, READY_STATE_CHANGE, newListener); + + const storedTask: Task = target[XHR_TASK]; + if (!storedTask) { + target[XHR_TASK] = task; + } + sendNative!.apply(target, data.args); + target[XHR_SCHEDULED] = true; + return task; + } + + function placeholderCallback() {} + + function clearTask(task: Task) { + const data = task.data; + // Note - ideally, we would call data.target.removeEventListener here, but it's too late + // to prevent it from firing. So instead, we store info for the event listener. + data.aborted = true; + return abortNative!.apply(data.target, data.args); + } + + const openNative = api.patchMethod( + XMLHttpRequestPrototype, + 'open', + () => + function (self: any, args: any[]) { + self[XHR_SYNC] = args[2] == false; + self[XHR_URL] = args[1]; + return openNative!.apply(self, args); + }, + ); + + const XMLHTTPREQUEST_SOURCE = 'XMLHttpRequest.send'; + const fetchTaskAborting = zoneSymbol('fetchTaskAborting'); + const fetchTaskScheduling = zoneSymbol('fetchTaskScheduling'); + const sendNative: Function | null = api.patchMethod( + XMLHttpRequestPrototype, + 'send', + () => + function (self: any, args: any[]) { + if ((Zone.current as any)[fetchTaskScheduling] === true) { + // a fetch is scheduling, so we are using xhr to polyfill fetch + // and because we already schedule macroTask for fetch, we should + // not schedule a macroTask for xhr again + return sendNative!.apply(self, args); + } + if (self[XHR_SYNC]) { + // if the XHR is sync there is no task to schedule, just execute the code. + return sendNative!.apply(self, args); + } else { + const options: XHROptions = { + target: self, + url: self[XHR_URL], + isPeriodic: false, + args: args, + aborted: false, + }; + const task = scheduleMacroTaskWithCurrentZone( + XMLHTTPREQUEST_SOURCE, + placeholderCallback, + options, + scheduleTask, + clearTask, + ); + if (self && self[XHR_ERROR_BEFORE_SCHEDULED] === true && !options.aborted && task.state === SCHEDULED) { + // xhr request throw error when send + // we should invoke task instead of leaving a scheduled + // pending macroTask + task.invoke(); + } + } + }, + ); + + const abortNative = api.patchMethod( + XMLHttpRequestPrototype, + 'abort', + () => + function (self: any, args: any[]) { + const task: Task = findPendingTask(self); + if (task && typeof task.type == 'string') { + // If the XHR has already completed, do nothing. + // If the XHR has already been aborted, do nothing. + // Fix #569, call abort multiple times before done will cause + // macroTask task count be negative number + if (task.cancelFn == null || (task.data && (task.data).aborted)) { + return; + } + task.zone.cancelTask(task); + } else if ((Zone.current as any)[fetchTaskAborting] === true) { + // the abort is called from fetch polyfill, we need to call native abort of XHR. + return abortNative!.apply(self, args); + } + // Otherwise, we are trying to abort an XHR which has not yet been sent, so there is no + // task + // to cancel. Do nothing. + }, + ); + } +}); From 67603f3c679e0246a85f38aa872c0bf83895033a Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Tue, 14 Apr 2026 12:00:58 -0700 Subject: [PATCH 04/19] feat(angular): improve vite hmr runtime wiring --- packages/angular/src/lib/application.ts | 144 +++++++++++------- .../src/lib/element-registry/common-views.ts | 121 ++++++++++----- .../src/lib/element-registry/registry.ts | 6 +- .../angular/src/lib/platform-nativescript.ts | 8 + 4 files changed, 181 insertions(+), 98 deletions(-) diff --git a/packages/angular/src/lib/application.ts b/packages/angular/src/lib/application.ts index 8a8ceb5c..f65c9f5d 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -278,7 +278,12 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { } const view = ref.injector.get(APP_ROOT_VIEW) as AppHostView | View; const newRoot = view instanceof AppHostView ? view.content : view; - console.log('[ng-hmr] setRootView: view from injector:', view?.constructor?.name, 'newRoot:', newRoot?.constructor?.name); + console.log( + '[ng-hmr] setRootView: view from injector:', + view?.constructor?.name, + 'newRoot:', + newRoot?.constructor?.name, + ); console.log('[ng-hmr] setRootView: launchEventDone:', launchEventDone, 'embedded:', options.embedded); if (NativeScriptDebug.isLogEnabled()) { NativeScriptDebug.bootstrapLog(`Setting RootView to ${newRoot}`); @@ -340,67 +345,92 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { ref.destroy(); return; } - mainModuleRef = ref; - - // Expose ApplicationRef for HMR to trigger change detection - // Check for ApplicationRef by duck-typing since instanceof can fail across module realms - const refAny = ref as any; - const isAppRef = refAny && typeof refAny.tick === 'function' && Array.isArray(refAny.components); - console.log('[ng-hmr] ref type check: isAppRef=', isAppRef, 'has tick=', typeof refAny?.tick === 'function', 'has components=', Array.isArray(refAny?.components)); - - if (isAppRef) { - global['__NS_ANGULAR_APP_REF__'] = ref; - // Mark boot complete for the HMR system - global['__NS_HMR_BOOT_COMPLETE__'] = true; - - // Register bootstrapped components for HMR lookup - if (!global['__NS_ANGULAR_COMPONENTS__']) { - global['__NS_ANGULAR_COMPONENTS__'] = {}; + + // When Zone.js is active and we're outside the Angular zone (which + // happens in HMR mode — the Promise .then() runs in the root zone), + // wrap the completion handler inside NgZone.run() so that: + // 1. resetRootView + component initialization happens inside the Angular zone + // 2. ngrx effects, store dispatches, and signal-triggered actions run inside NgZone + // 3. strictActionWithinNgZone checks pass for initial actions + // In zoneless apps (no Zone.js), skip the wrapping entirely. + const useZoneWrap = typeof Zone !== 'undefined' && !NgZone.isInAngularZone(); + const runInZone = (fn: () => void) => { + if (useZoneWrap) { + ref.injector.get(NgZone).run(fn); + } else { + fn(); } - // Get the component class from the first bootstrapped component - console.log('[ng-hmr] ApplicationRef components count:', refAny.components?.length); - if (refAny.components && refAny.components.length > 0) { - const componentRef = refAny.components[0]; - console.log('[ng-hmr] componentRef:', componentRef?.constructor?.name); - console.log('[ng-hmr] componentRef.componentType:', componentRef?.componentType?.name); - - // For Angular 17+ standalone components, the component type is on componentRef.componentType - // For older Angular, try componentRef.instance.constructor - let componentType = componentRef?.componentType; - if (!componentType && componentRef?.instance) { - componentType = componentRef.instance.constructor; + }; + runInZone(() => { + mainModuleRef = ref; + + // Expose ApplicationRef for HMR to trigger change detection + // Check for ApplicationRef by duck-typing since instanceof can fail across module realms + const refAny = ref as any; + const isAppRef = refAny && typeof refAny.tick === 'function' && Array.isArray(refAny.components); + console.log( + '[ng-hmr] ref type check: isAppRef=', + isAppRef, + 'has tick=', + typeof refAny?.tick === 'function', + 'has components=', + Array.isArray(refAny?.components), + ); + + if (isAppRef) { + global['__NS_ANGULAR_APP_REF__'] = ref; + // Mark boot complete for the HMR system + global['__NS_HMR_BOOT_COMPLETE__'] = true; + + // Register bootstrapped components for HMR lookup + if (!global['__NS_ANGULAR_COMPONENTS__']) { + global['__NS_ANGULAR_COMPONENTS__'] = {}; } - - if (componentType && componentType.name) { - global['__NS_ANGULAR_COMPONENTS__'][componentType.name] = componentType; - console.log('[ng-hmr] Registered component for HMR:', componentType.name); + // Get the component class from the first bootstrapped component + console.log('[ng-hmr] ApplicationRef components count:', refAny.components?.length); + if (refAny.components && refAny.components.length > 0) { + const componentRef = refAny.components[0]; + console.log('[ng-hmr] componentRef:', componentRef?.constructor?.name); + console.log('[ng-hmr] componentRef.componentType:', componentRef?.componentType?.name); + + // For Angular 17+ standalone components, the component type is on componentRef.componentType + // For older Angular, try componentRef.instance.constructor + let componentType = componentRef?.componentType; + if (!componentType && componentRef?.instance) { + componentType = componentRef.instance.constructor; + } + + if (componentType && componentType.name) { + global['__NS_ANGULAR_COMPONENTS__'][componentType.name] = componentType; + console.log('[ng-hmr] Registered component for HMR:', componentType.name); + } else { + console.log('[ng-hmr] Could not get componentType name'); + } } else { - console.log('[ng-hmr] Could not get componentType name'); + console.log('[ng-hmr] No components in ApplicationRef'); } } else { - console.log('[ng-hmr] No components in ApplicationRef'); - } - } else { - const appRef = ref.injector.get(ApplicationRef, null); - if (appRef) { - global['__NS_ANGULAR_APP_REF__'] = appRef; - // Mark boot complete for the HMR system - global['__NS_HMR_BOOT_COMPLETE__'] = true; + const appRef = ref.injector.get(ApplicationRef, null); + if (appRef) { + global['__NS_ANGULAR_APP_REF__'] = appRef; + // Mark boot complete for the HMR system + global['__NS_HMR_BOOT_COMPLETE__'] = true; + } } - } - (isAppRef ? refAny.components[0] : ref).onDestroy( - () => (mainModuleRef = mainModuleRef === ref ? null : mainModuleRef), - ); - updatePlatformRef(ref, reason); - const styleTag = ref.injector.get(NATIVESCRIPT_ROOT_MODULE_ID); - (isAppRef ? refAny.components[0] : ref).onDestroy(() => { - removeTaggedAdditionalCSS(styleTag); + (isAppRef ? refAny.components[0] : ref).onDestroy( + () => (mainModuleRef = mainModuleRef === ref ? null : mainModuleRef), + ); + updatePlatformRef(ref, reason); + const styleTag = ref.injector.get(NATIVESCRIPT_ROOT_MODULE_ID); + (isAppRef ? refAny.components[0] : ref).onDestroy(() => { + removeTaggedAdditionalCSS(styleTag); + }); + bootstrapped = true; + onMainBootstrap(); + emitModuleBootstrapEvent(ref, 'main', reason); + // bootstrapped component: (ref as any)._bootstrapComponents[0]; }); - bootstrapped = true; - onMainBootstrap(); - emitModuleBootstrapEvent(ref, 'main', reason); - // bootstrapped component: (ref as any)._bootstrapComponents[0]; }, (err) => { bootstrapped = true; @@ -544,7 +574,11 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { // Detect HMR environment (webpack or Vite) const isWebpackHot = !!import.meta['webpackHot']; - const isViteHot = !!import.meta['hot']; + // import.meta.hot is available when code goes through Vite's transform pipeline. + // When @nativescript/angular is pre-bundled in the vendor (esbuild), import.meta.hot + // won't exist. Fall back to the global placeholder flag that the NativeScript Vite + // HMR runtime sets during dev boot. + const isViteHot = !!import.meta['hot'] || !!(globalThis as any).__NS_DEV_PLACEHOLDER_ROOT_EARLY__; const isHotReloadEnabled = isWebpackHot || isViteHot; // Always expose HMR globals for both webpack and Vite HMR support diff --git a/packages/angular/src/lib/element-registry/common-views.ts b/packages/angular/src/lib/element-registry/common-views.ts index f4436ec9..cf86a3f8 100644 --- a/packages/angular/src/lib/element-registry/common-views.ts +++ b/packages/angular/src/lib/element-registry/common-views.ts @@ -1,49 +1,86 @@ -import { AbsoluteLayout, ActivityIndicator, Button, ContentView, DatePicker, DockLayout, FlexboxLayout, FormattedString, Frame, GridLayout, HtmlView, Image, Label, ListPicker, ListView, Page, Placeholder, Progress, ProxyViewContainer, Repeater, RootLayout, ScrollView, SearchBar, SegmentedBar, SegmentedBarItem, Slider, Span, SplitView, StackLayout, Switch, TabView, TextField, TextView, TimePicker, WebView, WrapLayout } from '@nativescript/core'; +import { + AbsoluteLayout, + ActivityIndicator, + Button, + ContentView, + DatePicker, + DockLayout, + FlexboxLayout, + FormattedString, + Frame, + GridLayout, + HtmlView, + Image, + Label, + ListPicker, + ListView, + Page, + Placeholder, + Progress, + ProxyViewContainer, + Repeater, + RootLayout, + ScrollView, + SearchBar, + SegmentedBar, + SegmentedBarItem, + Slider, + Span, + SplitView, + StackLayout, + Switch, + TabView, + TextField, + TextView, + TimePicker, + WebView, + WrapLayout, +} from '@nativescript/core'; import { formattedStringMeta, frameMeta, textBaseMeta } from './metas'; import { registerElement } from './registry'; // Register default NativeScript components // Note: ActionBar related components are registerd together with action-bar directives. export function registerNativeScriptViewComponents() { - if (!(global).__ngRegisteredViews) { - (global).__ngRegisteredViews = true; - registerElement('AbsoluteLayout', () => AbsoluteLayout); - registerElement('ActivityIndicator', () => ActivityIndicator); - registerElement('Button', () => Button, textBaseMeta); - registerElement('ContentView', () => ContentView); - registerElement('DatePicker', () => DatePicker); - registerElement('DockLayout', () => DockLayout); - registerElement('Frame', () => Frame, frameMeta); - registerElement('GridLayout', () => GridLayout); - registerElement('HtmlView', () => HtmlView); - registerElement('Image', () => Image); - // Parse5 changes tags to . WTF! - registerElement('img', () => Image); - registerElement('Label', () => Label, textBaseMeta); - registerElement('ListPicker', () => ListPicker); - registerElement('ListView', () => ListView); - registerElement('Page', () => Page); - registerElement('Placeholder', () => Placeholder); - registerElement('Progress', () => Progress); - registerElement('ProxyViewContainer', () => ProxyViewContainer); - registerElement('Repeater', () => Repeater); - registerElement('RootLayout', () => RootLayout); - registerElement('ScrollView', () => ScrollView); - registerElement('SearchBar', () => SearchBar); - registerElement('SegmentedBar', () => SegmentedBar); - registerElement('SegmentedBarItem', () => SegmentedBarItem); - registerElement('Slider', () => Slider); - registerElement('SplitView', () => SplitView); - registerElement('StackLayout', () => StackLayout); - registerElement('FlexboxLayout', () => FlexboxLayout); - registerElement('Switch', () => Switch); - registerElement('TabView', () => TabView); - registerElement('TextField', () => TextField, textBaseMeta); - registerElement('TextView', () => TextView, textBaseMeta); - registerElement('TimePicker', () => TimePicker); - registerElement('WebView', () => WebView); - registerElement('WrapLayout', () => WrapLayout); - registerElement('FormattedString', () => FormattedString, formattedStringMeta); - registerElement('Span', () => Span); - } + // No guard needed — registerElement calls Map.set which is idempotent. + // The old `elementMap.size > 0` guard could falsely skip registration + // in Vite HMR mode when elements were registered by a prior boot phase. + registerElement('AbsoluteLayout', () => AbsoluteLayout); + registerElement('ActivityIndicator', () => ActivityIndicator); + registerElement('Button', () => Button, textBaseMeta); + registerElement('ContentView', () => ContentView); + registerElement('DatePicker', () => DatePicker); + registerElement('DockLayout', () => DockLayout); + registerElement('Frame', () => Frame, frameMeta); + registerElement('GridLayout', () => GridLayout); + registerElement('HtmlView', () => HtmlView); + registerElement('Image', () => Image); + // Parse5 changes tags to . WTF! + registerElement('img', () => Image); + registerElement('Label', () => Label, textBaseMeta); + registerElement('ListPicker', () => ListPicker); + registerElement('ListView', () => ListView); + registerElement('Page', () => Page); + registerElement('Placeholder', () => Placeholder); + registerElement('Progress', () => Progress); + registerElement('ProxyViewContainer', () => ProxyViewContainer); + registerElement('Repeater', () => Repeater); + registerElement('RootLayout', () => RootLayout); + registerElement('ScrollView', () => ScrollView); + registerElement('SearchBar', () => SearchBar); + registerElement('SegmentedBar', () => SegmentedBar); + registerElement('SegmentedBarItem', () => SegmentedBarItem); + registerElement('Slider', () => Slider); + registerElement('SplitView', () => SplitView); + registerElement('StackLayout', () => StackLayout); + registerElement('FlexboxLayout', () => FlexboxLayout); + registerElement('Switch', () => Switch); + registerElement('TabView', () => TabView); + registerElement('TextField', () => TextField, textBaseMeta); + registerElement('TextView', () => TextView, textBaseMeta); + registerElement('TimePicker', () => TimePicker); + registerElement('WebView', () => WebView); + registerElement('WrapLayout', () => WrapLayout); + registerElement('FormattedString', () => FormattedString, formattedStringMeta); + registerElement('Span', () => Span); } diff --git a/packages/angular/src/lib/element-registry/registry.ts b/packages/angular/src/lib/element-registry/registry.ts index f5ac3f2a..c27dd905 100644 --- a/packages/angular/src/lib/element-registry/registry.ts +++ b/packages/angular/src/lib/element-registry/registry.ts @@ -4,7 +4,11 @@ import { ViewClassMeta } from '../views/view-types'; export type ViewResolver = () => any; -export const elementMap = new Map(); +// Use a global elementMap so the vendor bundle and HTTP-loaded module instances +// share the same element registry during Vite HMR (where two copies of +// @nativescript/angular can coexist in separate module realms). +export const elementMap: Map = + (globalThis as any).__NS_NG_ELEMENT_MAP__ || ((globalThis as any).__NS_NG_ELEMENT_MAP__ = new Map()); const camelCaseSplit = /([a-z0-9])([A-Z])/g; const defaultViewMeta: ViewClassMeta = { skipAddToDom: false }; diff --git a/packages/angular/src/lib/platform-nativescript.ts b/packages/angular/src/lib/platform-nativescript.ts index 3989a1ff..23a0541a 100644 --- a/packages/angular/src/lib/platform-nativescript.ts +++ b/packages/angular/src/lib/platform-nativescript.ts @@ -22,6 +22,7 @@ import { Color, GridLayout } from '@nativescript/core'; import { defaultPageFactory, ENABLE_REUSABE_VIEWS, PAGE_FACTORY, WRAP_CD_IN_TRANSACTION } from './tokens'; import { AppLaunchView } from './application'; import { NATIVESCRIPT_MODULE_PROVIDERS, NATIVESCRIPT_MODULE_STATIC_PROVIDERS } from './nativescript'; +import { registerNativeScriptViewComponents } from './element-registry'; export const defaultPageFactoryProvider = { provide: PAGE_FACTORY, useValue: defaultPageFactory }; export class NativeScriptSanitizer extends Sanitizer { @@ -204,6 +205,12 @@ export function bootstrapApplication( options?: NativeScriptApplicationConfig, context?: BootstrapContext, ) { + // Ensure NativeScript view components are registered in this module instance's + // element registry. During Vite HMR, the vendor bundle and HTTP-loaded modules + // may have separate module instances of @nativescript/angular, each with their + // own elementMap. Without this call, the HTTP instance's elementMap would be + // empty and the renderer would throw "No known component for element ...". + registerNativeScriptViewComponents(); return ɵinternalCreateApplication({ rootComponent: rootComponent, ...createProvidersConfig(options, context), @@ -211,6 +218,7 @@ export function bootstrapApplication( } export function createApplication(options?: NativeScriptApplicationConfig, context?: BootstrapContext) { + registerNativeScriptViewComponents(); return ɵinternalCreateApplication(createProvidersConfig(options, context)); } From 1fe42fcc8bf162a4fc6bc10d8cde9f7ba37f6fe4 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 15 Apr 2026 17:36:41 -0700 Subject: [PATCH 05/19] feat: ensure css applied on hmr http booted realm --- packages/angular/src/lib/application.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/angular/src/lib/application.ts b/packages/angular/src/lib/application.ts index f65c9f5d..76fa15a0 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -241,6 +241,18 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { }; let launchEventDone = true; let targetRootView: View = null; + const refreshRootViewCss = (expectedRoot?: View) => { + setTimeout(() => { + const currentRoot = Application.getRootView(); + if (!currentRoot || (expectedRoot && currentRoot !== expectedRoot)) { + return; + } + + try { + (currentRoot as any)._onCssStateChange?.(); + } catch {} + }, 0); + }; const setRootView = (ref: NgModuleRef | ApplicationRef | View) => { console.log('[ng-hmr] setRootView called, bootstrapId:', bootstrapId, 'ref type:', ref?.constructor?.name); if (bootstrapId === -1) { @@ -271,6 +283,7 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { Application.run({ create: () => ref }); } else if (launchEventDone) { Application.resetRootView({ create: () => ref }); + refreshRootViewCss(ref); } else { targetRootView = ref; } @@ -300,6 +313,7 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { childCount: (newRoot as any)?.getChildrenCount?.() ?? 'N/A', }); Application.resetRootView({ create: () => newRoot }); + refreshRootViewCss(newRoot); console.log('[ng-hmr] setRootView: Application.resetRootView returned'); // Check root view after reset setTimeout(() => { From 67086872e801548e5d25177cefd803ef6c5a7d75 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 16 Apr 2026 14:50:16 -0700 Subject: [PATCH 06/19] feat: hmr with compiled components and route preservation --- packages/angular/src/lib/application.ts | 25 +++++ .../lib/hmr-compiled-components-core.spec.ts | 27 ++++++ .../src/lib/hmr-compiled-components-core.ts | 19 ++++ .../lib/legacy/router/hmr-route-cache-core.ts | 61 ++++++++++++ .../lib/legacy/router/hmr-route-cache.spec.ts | 69 ++++++++++++++ .../lib/legacy/router/hmr-route-state-core.ts | 95 +++++++++++++++++++ .../lib/legacy/router/hmr-route-state.spec.ts | 55 +++++++++++ .../src/lib/legacy/router/hmr-route-state.ts | 55 +++++++++++ .../src/lib/legacy/router/router.module.ts | 28 ++++++ 9 files changed, 434 insertions(+) create mode 100644 packages/angular/src/lib/hmr-compiled-components-core.spec.ts create mode 100644 packages/angular/src/lib/hmr-compiled-components-core.ts create mode 100644 packages/angular/src/lib/legacy/router/hmr-route-cache-core.ts create mode 100644 packages/angular/src/lib/legacy/router/hmr-route-cache.spec.ts create mode 100644 packages/angular/src/lib/legacy/router/hmr-route-state-core.ts create mode 100644 packages/angular/src/lib/legacy/router/hmr-route-state.spec.ts create mode 100644 packages/angular/src/lib/legacy/router/hmr-route-state.ts diff --git a/packages/angular/src/lib/application.ts b/packages/angular/src/lib/application.ts index 76fa15a0..0ed0186d 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -1,5 +1,6 @@ import * as AngularCore from '@angular/core'; import { ApplicationRef, EnvironmentProviders, NgModuleRef, NgZone, PlatformRef, Provider } from '@angular/core'; +import { Router } from '@angular/router'; import { Application, ApplicationEventData, @@ -15,7 +16,9 @@ import { import { Observable, Subject } from 'rxjs'; import { filter, map, take } from 'rxjs/operators'; import { AppHostView } from './app-host-view'; +import { resetAngularHmrCompiledComponents } from './hmr-compiled-components-core'; import { NativeScriptLoadingService } from './loading.service'; +import { clearAngularHmrRouteConfigCaches } from './legacy/router/hmr-route-cache-core'; import { APP_ROOT_VIEW, DISABLE_ROOT_VIEW_HANDLING, NATIVESCRIPT_ROOT_MODULE_ID } from './tokens'; import { NativeScriptDebug } from './trace'; @@ -230,6 +233,17 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { let loadingModuleRef: NgModuleRef | ApplicationRef; let platformRef: PlatformRef = null; let bootstrapId = -1; + const clearAngularHmrRouteCaches = () => { + try { + const injector = (mainModuleRef as any)?.injector; + const router = injector?.get?.(Router, null); + const cleared = clearAngularHmrRouteConfigCaches(router?.config); + + if (cleared > 0) { + console.log('[ng-hmr] cleared Angular route caches before reboot:', cleared); + } + } catch {} + }; const updatePlatformRef = (moduleRef: NgModuleRef | ApplicationRef, reason: NgModuleReason) => { const newPlatformRef = moduleRef.injector.get(PlatformRef); if (newPlatformRef === platformRef) { @@ -555,12 +569,20 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { platformRef = null; }; const disposeLastModules = (reason: NgModuleReason) => { + if (reason === 'hotreload') { + clearAngularHmrRouteCaches(); + } + // reset bootstrap ID to make sure any modules bootstrapped after this are discarded bootstrapId = -1; destroyRef(loadingModuleRef, 'loading', reason); loadingModuleRef = null; destroyRef(mainModuleRef, 'main', reason); mainModuleRef = null; + + if (reason === 'hotreload') { + resetAngularHmrCompiledComponents(AngularCore as any); + } }; const launchCallback = profile('@nativescript/angular/platform-common.launchCallback', (args: LaunchEventData) => { launchEventDone = false; @@ -615,6 +637,9 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { global['__reboot_ng_modules__'] = (shouldDisposePlatform: boolean = false) => { console.log('[ng-hmr] __reboot_ng_modules__ called, shouldDisposePlatform:', shouldDisposePlatform); console.log('[ng-hmr] current bootstrapId:', bootstrapId, 'mainModuleRef:', !!mainModuleRef); + try { + global['__NS_CAPTURE_ANGULAR_HMR_ROUTE__']?.(); + } catch {} disposeLastModules('hotreload'); console.log('[ng-hmr] after disposeLastModules, bootstrapId:', bootstrapId); if (shouldDisposePlatform) { diff --git a/packages/angular/src/lib/hmr-compiled-components-core.spec.ts b/packages/angular/src/lib/hmr-compiled-components-core.spec.ts new file mode 100644 index 00000000..9074b8fb --- /dev/null +++ b/packages/angular/src/lib/hmr-compiled-components-core.spec.ts @@ -0,0 +1,27 @@ +import { resetAngularHmrCompiledComponents } from './hmr-compiled-components-core'; + +describe('Angular HMR compiled component reset', () => { + it('calls Angular internal compiled-component reset when available', () => { + const core = { + ɵresetCompiledComponents: jest.fn(), + }; + + expect(resetAngularHmrCompiledComponents(core)).toBe(true); + expect(core.ɵresetCompiledComponents).toHaveBeenCalledTimes(1); + }); + + it('returns false when Angular core does not expose the reset hook', () => { + expect(resetAngularHmrCompiledComponents({})).toBe(false); + }); + + it('swallows reset failures so HMR disposal can continue', () => { + const core = { + ɵresetCompiledComponents: jest.fn(() => { + throw new Error('boom'); + }), + }; + + expect(resetAngularHmrCompiledComponents(core)).toBe(false); + expect(core.ɵresetCompiledComponents).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/packages/angular/src/lib/hmr-compiled-components-core.ts b/packages/angular/src/lib/hmr-compiled-components-core.ts new file mode 100644 index 00000000..b0cedb3f --- /dev/null +++ b/packages/angular/src/lib/hmr-compiled-components-core.ts @@ -0,0 +1,19 @@ +type AngularCoreWithCompiledComponentReset = { + ɵresetCompiledComponents?: () => void; +}; + +export function resetAngularHmrCompiledComponents( + core: AngularCoreWithCompiledComponentReset | null | undefined, +): boolean { + const resetCompiledComponents = core?.ɵresetCompiledComponents; + if (typeof resetCompiledComponents !== 'function') { + return false; + } + + try { + resetCompiledComponents.call(core); + return true; + } catch { + return false; + } +} \ No newline at end of file diff --git a/packages/angular/src/lib/legacy/router/hmr-route-cache-core.ts b/packages/angular/src/lib/legacy/router/hmr-route-cache-core.ts new file mode 100644 index 00000000..c456b9f4 --- /dev/null +++ b/packages/angular/src/lib/legacy/router/hmr-route-cache-core.ts @@ -0,0 +1,61 @@ +type AngularHmrRouteLike = { + children?: AngularHmrRouteLike[]; + _injector?: unknown; + _loadedComponent?: unknown; + _loadedInjector?: unknown; + _loadedRoutes?: AngularHmrRouteLike[]; +}; + +const ROUTE_CACHE_KEYS = ['_loadedComponent', '_loadedInjector', '_loadedRoutes', '_injector'] as const; + +function clearRouteCacheField(route: Record, key: (typeof ROUTE_CACHE_KEYS)[number]): boolean { + if (!Object.prototype.hasOwnProperty.call(route, key) && route[key] === undefined) { + return false; + } + + try { + delete route[key]; + } catch { + try { + route[key] = undefined; + } catch {} + } + + return true; +} + +export function clearAngularHmrRouteConfigCaches(routes: AngularHmrRouteLike[] | undefined | null): number { + const seen = new Set(); + let cleared = 0; + + const visitRoute = (route: AngularHmrRouteLike | undefined | null): void => { + if (!route || seen.has(route)) { + return; + } + + seen.add(route); + + const childRoutes = Array.isArray(route.children) ? route.children : []; + const loadedRoutes = Array.isArray(route._loadedRoutes) ? route._loadedRoutes : []; + + for (const childRoute of childRoutes) { + visitRoute(childRoute); + } + + for (const loadedRoute of loadedRoutes) { + visitRoute(loadedRoute); + } + + for (const key of ROUTE_CACHE_KEYS) { + if (clearRouteCacheField(route as Record, key)) { + cleared += 1; + } + } + }; + + for (const route of Array.isArray(routes) ? routes : []) { + visitRoute(route); + } + + return cleared; +} \ No newline at end of file diff --git a/packages/angular/src/lib/legacy/router/hmr-route-cache.spec.ts b/packages/angular/src/lib/legacy/router/hmr-route-cache.spec.ts new file mode 100644 index 00000000..c1936f50 --- /dev/null +++ b/packages/angular/src/lib/legacy/router/hmr-route-cache.spec.ts @@ -0,0 +1,69 @@ +import { clearAngularHmrRouteConfigCaches } from './hmr-route-cache-core'; + +describe('Angular HMR route cache clearing', () => { + it('clears lazy route caches recursively while preserving public route fields', () => { + const grandchild = { + path: 'details', + _loadedComponent: { name: 'DetailsComponent' }, + _loadedInjector: { token: 'details' }, + }; + const child = { + path: 'survey', + children: [grandchild], + _loadedComponent: { name: 'SurveyComponent' }, + _loadedInjector: { token: 'survey' }, + _injector: { token: 'child-injector' }, + }; + const route = { + path: 'onboarding-flow', + children: [child], + _loadedRoutes: [ + { + path: 'lazy', + _loadedComponent: { name: 'LazyComponent' }, + _loadedRoutes: [ + { + path: 'nested', + _injector: { token: 'nested-injector' }, + }, + ], + }, + ], + }; + + const cleared = clearAngularHmrRouteConfigCaches([route]); + + expect(cleared).toBe(9); + expect(route.path).toBe('onboarding-flow'); + expect(child.path).toBe('survey'); + expect(grandchild.path).toBe('details'); + expect((route as any)._loadedRoutes).toBeUndefined(); + expect((child as any)._loadedComponent).toBeUndefined(); + expect((child as any)._loadedInjector).toBeUndefined(); + expect((child as any)._injector).toBeUndefined(); + expect((grandchild as any)._loadedComponent).toBeUndefined(); + expect((grandchild as any)._loadedInjector).toBeUndefined(); + }); + + it('does not loop forever when route graphs reuse the same child object', () => { + const shared = { + path: 'shared', + _loadedComponent: { name: 'SharedComponent' }, + }; + const routes = [ + { + path: 'a', + children: [shared], + }, + { + path: 'b', + _loadedRoutes: [shared], + }, + ]; + + const cleared = clearAngularHmrRouteConfigCaches(routes as any); + + expect(cleared).toBe(2); + expect((shared as any)._loadedComponent).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/packages/angular/src/lib/legacy/router/hmr-route-state-core.ts b/packages/angular/src/lib/legacy/router/hmr-route-state-core.ts new file mode 100644 index 00000000..06cbec17 --- /dev/null +++ b/packages/angular/src/lib/legacy/router/hmr-route-state-core.ts @@ -0,0 +1,95 @@ +type AngularHmrRouteState = { + url: string; + source: string; + timestamp: number; +}; + +const CURRENT_ROUTE_KEY = '__NS_ANGULAR_HMR_CURRENT_ROUTE__'; +const PENDING_START_PATH_KEY = '__NS_ANGULAR_HMR_PENDING_START_PATH__'; +const CAPTURE_ROUTE_KEY = '__NS_CAPTURE_ANGULAR_HMR_ROUTE__'; + +function getGlobalState(): any { + return globalThis as any; +} + +export function normalizeAngularHmrRouteUrl(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + if (trimmed.startsWith('/')) { + return trimmed; + } + + if (trimmed.startsWith('?') || trimmed.startsWith('#')) { + return `/${trimmed}`; + } + + return `/${trimmed.replace(/^\/+/, '')}`; +} + +export function writeAngularHmrRouteState( + value: unknown, + options: { + pending?: boolean; + source: string; + }, +): string | null { + const url = normalizeAngularHmrRouteUrl(value); + if (!url) { + return null; + } + + const state: AngularHmrRouteState = { + url, + source: options.source, + timestamp: Date.now(), + }; + + const g = getGlobalState(); + g[CURRENT_ROUTE_KEY] = state; + if (options.pending) { + g[PENDING_START_PATH_KEY] = state; + } + + return url; +} + +export function captureAngularHmrPendingStartPath(value: unknown, source = 'hmr-reboot'): string | null { + return writeAngularHmrRouteState(value, { pending: true, source }); +} + +export function readAngularHmrPendingStartPath(): string { + const g = getGlobalState(); + return normalizeAngularHmrRouteUrl(g[PENDING_START_PATH_KEY]?.url ?? g[PENDING_START_PATH_KEY]) || ''; +} + +export function invokeAngularHmrRouteCapture(): string | null { + const g = getGlobalState(); + const capture = g[CAPTURE_ROUTE_KEY]; + if (typeof capture === 'function') { + try { + return capture(); + } catch { + // Fall back to the last known router url when the active capture hook fails. + } + } + + return captureAngularHmrPendingStartPath(g[CURRENT_ROUTE_KEY]?.url ?? g[CURRENT_ROUTE_KEY], 'hmr-fallback'); +} + +export function installAngularHmrRouteCaptureHook(capture: () => string | null): () => void { + const g = getGlobalState(); + g[CAPTURE_ROUTE_KEY] = capture; + + return () => { + if (g[CAPTURE_ROUTE_KEY] === capture) { + delete g[CAPTURE_ROUTE_KEY]; + } + }; +} \ No newline at end of file diff --git a/packages/angular/src/lib/legacy/router/hmr-route-state.spec.ts b/packages/angular/src/lib/legacy/router/hmr-route-state.spec.ts new file mode 100644 index 00000000..e4b4d753 --- /dev/null +++ b/packages/angular/src/lib/legacy/router/hmr-route-state.spec.ts @@ -0,0 +1,55 @@ +import { + captureAngularHmrPendingStartPath, + invokeAngularHmrRouteCapture, + installAngularHmrRouteCaptureHook, + normalizeAngularHmrRouteUrl, + readAngularHmrPendingStartPath, + writeAngularHmrRouteState, +} from './hmr-route-state-core'; + +describe('Angular HMR route state', () => { + const g = globalThis as any; + + afterEach(() => { + delete g.__NS_ANGULAR_HMR_CURRENT_ROUTE__; + delete g.__NS_ANGULAR_HMR_PENDING_START_PATH__; + delete g.__NS_CAPTURE_ANGULAR_HMR_ROUTE__; + }); + + it('normalizes route-like values to app paths', () => { + expect(normalizeAngularHmrRouteUrl('/talk/library')).toBe('/talk/library'); + expect(normalizeAngularHmrRouteUrl('talk/library')).toBe('/talk/library'); + expect(normalizeAngularHmrRouteUrl('?tab=list')).toBe('/?tab=list'); + expect(normalizeAngularHmrRouteUrl('')).toBeNull(); + }); + + it('returns the pending HMR start path from a captured snapshot', () => { + captureAngularHmrPendingStartPath('chatbot/42?mode=create'); + + expect(readAngularHmrPendingStartPath()).toBe('/chatbot/42?mode=create'); + expect(g.__NS_ANGULAR_HMR_PENDING_START_PATH__).toMatchObject({ + url: '/chatbot/42?mode=create', + source: 'hmr-reboot', + }); + }); + + it('uses the installed capture hook before falling back to the last route snapshot', () => { + const dispose = installAngularHmrRouteCaptureHook(() => captureAngularHmrPendingStartPath('/talk/library?tab=saved')); + + try { + expect(invokeAngularHmrRouteCapture()).toBe('/talk/library?tab=saved'); + expect(readAngularHmrPendingStartPath()).toBe('/talk/library?tab=saved'); + } finally { + dispose(); + } + + expect(g.__NS_CAPTURE_ANGULAR_HMR_ROUTE__).toBeUndefined(); + }); + + it('falls back to the last known route when no capture hook is installed', () => { + writeAngularHmrRouteState('/profile?tab=goals', { source: 'navigation-end' }); + + expect(invokeAngularHmrRouteCapture()).toBe('/profile?tab=goals'); + expect(readAngularHmrPendingStartPath()).toBe('/profile?tab=goals'); + }); +}); \ No newline at end of file diff --git a/packages/angular/src/lib/legacy/router/hmr-route-state.ts b/packages/angular/src/lib/legacy/router/hmr-route-state.ts new file mode 100644 index 00000000..7a5eeb99 --- /dev/null +++ b/packages/angular/src/lib/legacy/router/hmr-route-state.ts @@ -0,0 +1,55 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { + installAngularHmrRouteCaptureHook, + readAngularHmrPendingStartPath, + writeAngularHmrRouteState, +} from './hmr-route-state-core'; + +export { captureAngularHmrPendingStartPath, invokeAngularHmrRouteCapture, normalizeAngularHmrRouteUrl } from './hmr-route-state-core'; +export { readAngularHmrPendingStartPath } from './hmr-route-state-core'; + +@Injectable() +export class NativeScriptAngularHmrRouteTracker implements OnDestroy { + private subscription?: Subscription; + private disposeCaptureHook?: () => void; + + constructor(private readonly router: Router) { + if (!this.isHmrEnabled()) { + return; + } + + this.disposeCaptureHook = this.installCaptureHook(); + this.captureCurrentRoute('bootstrap'); + this.subscription = this.router.events.subscribe((event) => { + if (event instanceof NavigationEnd) { + writeAngularHmrRouteState(event.urlAfterRedirects || event.url, { + source: 'navigation-end', + }); + } + }); + } + + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + this.disposeCaptureHook?.(); + } + + private captureCurrentRoute(source: string): string | null { + return writeAngularHmrRouteState(this.router.url, { + pending: source === 'hmr-reboot', + source, + }); + } + + private installCaptureHook(): () => void { + return installAngularHmrRouteCaptureHook(() => this.captureCurrentRoute('hmr-reboot')); + } + + private isHmrEnabled(): boolean { + const g = globalThis as any; + return !!g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__ || typeof g.__reboot_ng_modules__ === 'function'; + } +} \ No newline at end of file diff --git a/packages/angular/src/lib/legacy/router/router.module.ts b/packages/angular/src/lib/legacy/router/router.module.ts index b0171695..f418e737 100644 --- a/packages/angular/src/lib/legacy/router/router.module.ts +++ b/packages/angular/src/lib/legacy/router/router.module.ts @@ -1,10 +1,13 @@ import { + APP_BOOTSTRAP_LISTENER, + ENVIRONMENT_INITIALIZER, NgModule, ModuleWithProviders, NO_ERRORS_SCHEMA, Optional, Provider, SkipSelf, + inject, makeEnvironmentProviders, } from '@angular/core'; import { @@ -31,6 +34,7 @@ import { FrameService } from '../frame.service'; import { NSEmptyOutletComponent } from './ns-empty-outlet.component'; import { NativeScriptCommonModule } from '../../nativescript-common.module'; import { START_PATH } from '../../tokens'; +import { NativeScriptAngularHmrRouteTracker, readAngularHmrPendingStartPath } from './hmr-route-state'; import { ComponentInputBindingOptions, INPUT_BINDER, RoutedComponentInputBinder } from './router-component-input-binder'; export { PageRoute } from './page-router-outlet'; @@ -74,6 +78,10 @@ export class NativeScriptRouterModule { ngModule: NativeScriptRouterModule, providers: [ ...RouterModule.forRoot(routes, config).providers, + { + provide: START_PATH, + useFactory: readAngularHmrPendingStartPath, + }, { provide: NSLocationStrategy, useFactory: provideLocationStrategy, @@ -85,6 +93,13 @@ export class NativeScriptRouterModule { RouterExtensions, NSRouteReuseStrategy, { provide: RouteReuseStrategy, useExisting: NSRouteReuseStrategy }, + NativeScriptAngularHmrRouteTracker, + { + provide: APP_BOOTSTRAP_LISTENER, + multi: true, + deps: [NativeScriptAngularHmrRouteTracker], + useFactory: () => () => undefined, + }, config?.bindToComponentInputs ? inputBinderProviders(typeof config.bindToComponentInputs === 'object' ? config.bindToComponentInputs : {}) : [], @@ -104,6 +119,10 @@ export function provideNativeScriptRouter(routes: Routes, ...features: RouterFea const hasInputBinding = features.some((f: any) => f.ɵkind === COMPONENT_INPUT_BINDING_FEATURE_KIND); return makeEnvironmentProviders([ provideRouter(routes, ...features), + { + provide: START_PATH, + useFactory: readAngularHmrPendingStartPath, + }, { provide: NSLocationStrategy, useFactory: provideLocationStrategy, @@ -115,6 +134,15 @@ export function provideNativeScriptRouter(routes: Routes, ...features: RouterFea RouterExtensions, NSRouteReuseStrategy, { provide: RouteReuseStrategy, useExisting: NSRouteReuseStrategy }, + NativeScriptAngularHmrRouteTracker, + { + provide: ENVIRONMENT_INITIALIZER, + multi: true, + useValue: () => { + inject(NativeScriptAngularHmrRouteTracker); + }, + }, + // {provide: APP_BOOTSTRAP_LISTENER, multi: true, useFactory: getBootstrapListener}, hasInputBinding ? inputBinderProviders() : [], ]); } From 2feb8e0321616eb6ccd4c060b7335e727c4948e7 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 19 Apr 2026 12:06:02 -0700 Subject: [PATCH 07/19] feat: hmr applying while keeping route state preserved --- packages/angular/src/lib/application.ts | 22 +++--- .../lib/hmr-compiled-components-core.spec.ts | 35 +++++++++- .../src/lib/hmr-compiled-components-core.ts | 22 ++++++ .../src/lib/root-transition-guard.spec.ts | 57 +++++++++++++++ .../angular/src/lib/root-transition-guard.ts | 70 +++++++++++++++++++ 5 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 packages/angular/src/lib/root-transition-guard.spec.ts create mode 100644 packages/angular/src/lib/root-transition-guard.ts diff --git a/packages/angular/src/lib/application.ts b/packages/angular/src/lib/application.ts index 0ed0186d..d71725c0 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -16,16 +16,21 @@ import { import { Observable, Subject } from 'rxjs'; import { filter, map, take } from 'rxjs/operators'; import { AppHostView } from './app-host-view'; -import { resetAngularHmrCompiledComponents } from './hmr-compiled-components-core'; +import { + getAngularCoreForHmrReset, + rememberAngularCoreForHmr, + resetAngularHmrCompiledComponents, +} from './hmr-compiled-components-core'; import { NativeScriptLoadingService } from './loading.service'; import { clearAngularHmrRouteConfigCaches } from './legacy/router/hmr-route-cache-core'; +import { createAngularRootTransitionGuard } from './root-transition-guard'; import { APP_ROOT_VIEW, DISABLE_ROOT_VIEW_HANDLING, NATIVESCRIPT_ROOT_MODULE_ID } from './tokens'; import { NativeScriptDebug } from './trace'; // Store the original @angular/core module for HMR // This is crucial because HMR imports a fresh @angular/core with empty LView tracking // We need to use the original one that has the registered LViews -(globalThis as any).__NS_ANGULAR_CORE__ = AngularCore; +rememberAngularCoreForHmr(AngularCore as any, globalThis as any); export interface AppLaunchView extends LayoutBase { // called when the animation is to begin @@ -255,6 +260,7 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { }; let launchEventDone = true; let targetRootView: View = null; + const rootTransitionGuard = createAngularRootTransitionGuard(globalThis as any); const refreshRootViewCss = (expectedRoot?: View) => { setTimeout(() => { const currentRoot = Application.getRootView(); @@ -296,7 +302,7 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { if (options.embedded) { Application.run({ create: () => ref }); } else if (launchEventDone) { - Application.resetRootView({ create: () => ref }); + rootTransitionGuard.runApplicationResetRootView(Application, () => ref, ref?.constructor?.name || 'View'); refreshRootViewCss(ref); } else { targetRootView = ref; @@ -326,7 +332,7 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { parent: newRoot?.parent?.constructor?.name, childCount: (newRoot as any)?.getChildrenCount?.() ?? 'N/A', }); - Application.resetRootView({ create: () => newRoot }); + rootTransitionGuard.runApplicationResetRootView(Application, () => newRoot, newRoot?.constructor?.name || 'View'); refreshRootViewCss(newRoot); console.log('[ng-hmr] setRootView: Application.resetRootView returned'); // Check root view after reset @@ -353,6 +359,10 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { const bootstrapRoot = (reason: NgModuleReason) => { console.log('[ng-hmr] bootstrapRoot called, reason:', reason); try { + if (reason === 'hotreload') { + resetAngularHmrCompiledComponents(getAngularCoreForHmrReset(AngularCore as any, globalThis as any)); + } + bootstrapId = Date.now(); console.log('[ng-hmr] bootstrapRoot: new bootstrapId:', bootstrapId); const currentBootstrapId = bootstrapId; @@ -579,10 +589,6 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { loadingModuleRef = null; destroyRef(mainModuleRef, 'main', reason); mainModuleRef = null; - - if (reason === 'hotreload') { - resetAngularHmrCompiledComponents(AngularCore as any); - } }; const launchCallback = profile('@nativescript/angular/platform-common.launchCallback', (args: LaunchEventData) => { launchEventDone = false; diff --git a/packages/angular/src/lib/hmr-compiled-components-core.spec.ts b/packages/angular/src/lib/hmr-compiled-components-core.spec.ts index 9074b8fb..f992181c 100644 --- a/packages/angular/src/lib/hmr-compiled-components-core.spec.ts +++ b/packages/angular/src/lib/hmr-compiled-components-core.spec.ts @@ -1,4 +1,8 @@ -import { resetAngularHmrCompiledComponents } from './hmr-compiled-components-core'; +import { + getAngularCoreForHmrReset, + rememberAngularCoreForHmr, + resetAngularHmrCompiledComponents, +} from './hmr-compiled-components-core'; describe('Angular HMR compiled component reset', () => { it('calls Angular internal compiled-component reset when available', () => { @@ -24,4 +28,33 @@ describe('Angular HMR compiled component reset', () => { expect(resetAngularHmrCompiledComponents(core)).toBe(false); expect(core.ɵresetCompiledComponents).toHaveBeenCalledTimes(1); }); + + it('prefers the preserved global Angular core object for resets', () => { + const originalCore = { + ɵresetCompiledComponents: jest.fn(), + }; + const replacementCore = { + ɵresetCompiledComponents: jest.fn(), + }; + const globalObj: any = { + __NS_ANGULAR_CORE__: originalCore, + }; + + expect(getAngularCoreForHmrReset(replacementCore, globalObj)).toBe(originalCore); + }); + + it('remembers the first Angular core object and does not replace it later', () => { + const originalCore = { + ɵresetCompiledComponents: jest.fn(), + }; + const replacementCore = { + ɵresetCompiledComponents: jest.fn(), + }; + const globalObj: any = {}; + + expect(rememberAngularCoreForHmr(originalCore, globalObj)).toBe(originalCore); + expect(globalObj.__NS_ANGULAR_CORE__).toBe(originalCore); + expect(rememberAngularCoreForHmr(replacementCore, globalObj)).toBe(originalCore); + expect(globalObj.__NS_ANGULAR_CORE__).toBe(originalCore); + }); }); \ No newline at end of file diff --git a/packages/angular/src/lib/hmr-compiled-components-core.ts b/packages/angular/src/lib/hmr-compiled-components-core.ts index b0cedb3f..1a29508c 100644 --- a/packages/angular/src/lib/hmr-compiled-components-core.ts +++ b/packages/angular/src/lib/hmr-compiled-components-core.ts @@ -2,6 +2,28 @@ type AngularCoreWithCompiledComponentReset = { ɵresetCompiledComponents?: () => void; }; +type AngularCoreHolder = { + __NS_ANGULAR_CORE__?: AngularCoreWithCompiledComponentReset | null; +}; + +export function getAngularCoreForHmrReset( + core: AngularCoreWithCompiledComponentReset | null | undefined, + globalObj: AngularCoreHolder = globalThis as AngularCoreHolder, +): AngularCoreWithCompiledComponentReset | null | undefined { + return globalObj.__NS_ANGULAR_CORE__ || core; +} + +export function rememberAngularCoreForHmr( + core: AngularCoreWithCompiledComponentReset | null | undefined, + globalObj: AngularCoreHolder = globalThis as AngularCoreHolder, +): AngularCoreWithCompiledComponentReset | null | undefined { + if (!globalObj.__NS_ANGULAR_CORE__ && core) { + globalObj.__NS_ANGULAR_CORE__ = core; + } + + return getAngularCoreForHmrReset(core, globalObj); +} + export function resetAngularHmrCompiledComponents( core: AngularCoreWithCompiledComponentReset | null | undefined, ): boolean { diff --git a/packages/angular/src/lib/root-transition-guard.spec.ts b/packages/angular/src/lib/root-transition-guard.spec.ts new file mode 100644 index 00000000..faf93124 --- /dev/null +++ b/packages/angular/src/lib/root-transition-guard.spec.ts @@ -0,0 +1,57 @@ +import { createAngularRootTransitionGuard } from './root-transition-guard'; + +describe('Angular root transition guard', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('marks the root transition during resetRootView and clears it after the timeout', () => { + jest.useFakeTimers(); + + const globalObj: any = {}; + const createdRoot = { type: 'RootLayout' }; + const resetRootView = jest.fn((entry: { create: () => unknown }) => entry.create()); + const guard = createAngularRootTransitionGuard(globalObj); + + const result = guard.runApplicationResetRootView({ resetRootView }, () => createdRoot, 'RootLayout', 250); + + expect(result).toBe(createdRoot); + expect(resetRootView).toHaveBeenCalledTimes(1); + expect(globalObj.__NS_DEV_ROOT_TRANSITION_IN_PROGRESS__).toBe(true); + expect(globalObj.__NS_DEV_ROOT_TRANSITION_REASON__).toBe('RootLayout'); + + jest.advanceTimersByTime(249); + expect(globalObj.__NS_DEV_ROOT_TRANSITION_IN_PROGRESS__).toBe(true); + + jest.advanceTimersByTime(1); + expect(globalObj.__NS_DEV_ROOT_TRANSITION_IN_PROGRESS__).toBeUndefined(); + expect(globalObj.__NS_DEV_ROOT_TRANSITION_REASON__).toBeUndefined(); + }); + + it('still clears the transition window when resetRootView throws', () => { + jest.useFakeTimers(); + + const globalObj: any = {}; + const guard = createAngularRootTransitionGuard(globalObj); + + expect(() => + guard.runApplicationResetRootView( + { + resetRootView: () => { + throw new Error('boom'); + }, + }, + () => ({ type: 'RootLayout' }), + 'RootLayout', + 250, + ), + ).toThrow('boom'); + + expect(globalObj.__NS_DEV_ROOT_TRANSITION_IN_PROGRESS__).toBe(true); + expect(globalObj.__NS_DEV_ROOT_TRANSITION_REASON__).toBe('RootLayout'); + + jest.runAllTimers(); + expect(globalObj.__NS_DEV_ROOT_TRANSITION_IN_PROGRESS__).toBeUndefined(); + expect(globalObj.__NS_DEV_ROOT_TRANSITION_REASON__).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/packages/angular/src/lib/root-transition-guard.ts b/packages/angular/src/lib/root-transition-guard.ts new file mode 100644 index 00000000..ab9f01a6 --- /dev/null +++ b/packages/angular/src/lib/root-transition-guard.ts @@ -0,0 +1,70 @@ +type RootTransitionGlobal = { + __NS_DEV_ROOT_TRANSITION_IN_PROGRESS__?: boolean; + __NS_DEV_ROOT_TRANSITION_REASON__?: string; +}; + +type RootTransitionTimers = { + setTimeout: typeof setTimeout; + clearTimeout: typeof clearTimeout; +}; + +type ResetRootViewLike = { + resetRootView: (entry?: any) => unknown; +}; + +export function createAngularRootTransitionGuard( + globalObj: RootTransitionGlobal = globalThis as RootTransitionGlobal, + timers: RootTransitionTimers = { setTimeout, clearTimeout }, +) { + let clearTimer: ReturnType | null = null; + + const clear = () => { + if (clearTimer) { + timers.clearTimeout(clearTimer); + clearTimer = null; + } + + delete globalObj.__NS_DEV_ROOT_TRANSITION_IN_PROGRESS__; + delete globalObj.__NS_DEV_ROOT_TRANSITION_REASON__; + }; + + const mark = (detail: string) => { + clear(); + globalObj.__NS_DEV_ROOT_TRANSITION_IN_PROGRESS__ = true; + globalObj.__NS_DEV_ROOT_TRANSITION_REASON__ = detail; + }; + + const scheduleClear = (delayMs = 250) => { + if (clearTimer) { + timers.clearTimeout(clearTimer); + } + + clearTimer = timers.setTimeout(() => { + clearTimer = null; + delete globalObj.__NS_DEV_ROOT_TRANSITION_IN_PROGRESS__; + delete globalObj.__NS_DEV_ROOT_TRANSITION_REASON__; + }, delayMs); + }; + + const runApplicationResetRootView = ( + applicationLike: ResetRootViewLike, + createRoot: () => unknown, + detail: string, + delayMs = 250, + ) => { + mark(detail); + + try { + return applicationLike.resetRootView({ create: () => createRoot() }); + } finally { + scheduleClear(delayMs); + } + }; + + return { + clear, + mark, + scheduleClear, + runApplicationResetRootView, + }; +} \ No newline at end of file From cd89b6be2d0fcd35dddf42d501834f7176c9e3e7 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 19 Apr 2026 22:55:28 -0700 Subject: [PATCH 08/19] feat: improve hmr conditions around routing --- packages/angular/src/lib/application.ts | 55 ++++++++++--- .../lib/hmr-compiled-components-core.spec.ts | 16 ++++ .../src/lib/hmr-compiled-components-core.ts | 11 +++ .../legacy/router/hmr-route-bootstrap-core.ts | 55 +++++++++++++ .../legacy/router/hmr-route-bootstrap.spec.ts | 58 +++++++++++++ .../lib/legacy/router/hmr-route-cache-core.ts | 20 ++++- .../lib/legacy/router/hmr-route-cache.spec.ts | 18 ++++- .../router/ns-location-strategy.spec.ts | 81 +++++++++++++++++++ .../lib/legacy/router/ns-location-strategy.ts | 23 +++++- .../router/ns-route-reuse-strategy.spec.ts | 57 +++++++++++++ .../legacy/router/ns-route-reuse-strategy.ts | 19 ++++- .../src/lib/legacy/router/router.module.ts | 7 +- 12 files changed, 396 insertions(+), 24 deletions(-) create mode 100644 packages/angular/src/lib/legacy/router/hmr-route-bootstrap-core.ts create mode 100644 packages/angular/src/lib/legacy/router/hmr-route-bootstrap.spec.ts create mode 100644 packages/angular/src/lib/legacy/router/ns-location-strategy.spec.ts create mode 100644 packages/angular/src/lib/legacy/router/ns-route-reuse-strategy.spec.ts diff --git a/packages/angular/src/lib/application.ts b/packages/angular/src/lib/application.ts index d71725c0..5186e769 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -20,9 +20,12 @@ import { getAngularCoreForHmrReset, rememberAngularCoreForHmr, resetAngularHmrCompiledComponents, + setAngularCoreForHmr, } from './hmr-compiled-components-core'; import { NativeScriptLoadingService } from './loading.service'; import { clearAngularHmrRouteConfigCaches } from './legacy/router/hmr-route-cache-core'; +import { NSLocationStrategy } from './legacy/router/ns-location-strategy'; +import { NSRouteReuseStrategy } from './legacy/router/ns-route-reuse-strategy'; import { createAngularRootTransitionGuard } from './root-transition-guard'; import { APP_ROOT_VIEW, DISABLE_ROOT_VIEW_HANDLING, NATIVESCRIPT_ROOT_MODULE_ID } from './tokens'; import { NativeScriptDebug } from './trace'; @@ -32,6 +35,9 @@ import { NativeScriptDebug } from './trace'; // We need to use the original one that has the registered LViews rememberAngularCoreForHmr(AngularCore as any, globalThis as any); +const angularHmrGlobal = globalThis as any; +angularHmrGlobal.__NS_REMEMBER_ANGULAR_CORE__ = (core: any) => setAngularCoreForHmr(core, angularHmrGlobal); + export interface AppLaunchView extends LayoutBase { // called when the animation is to begin startAnimation?: () => void; @@ -234,18 +240,43 @@ export interface ApplicationConfig { } export function runNativeScriptAngularApp(options: AppRunOptions) { + const hmrGlobal = globalThis as any; + + if (hmrGlobal.__NS_ANGULAR_HMR_REGISTER_ONLY__ && typeof hmrGlobal.__NS_UPDATE_ANGULAR_APP_OPTIONS__ === 'function') { + hmrGlobal.__NS_UPDATE_ANGULAR_APP_OPTIONS__(options); + return; + } + + let currentOptions = options; let mainModuleRef: NgModuleRef | ApplicationRef = null; let loadingModuleRef: NgModuleRef | ApplicationRef; let platformRef: PlatformRef = null; let bootstrapId = -1; + + hmrGlobal.__NS_UPDATE_ANGULAR_APP_OPTIONS__ = (nextOptions: AppRunOptions) => { + currentOptions = nextOptions; + }; + const clearAngularHmrRouteCaches = () => { try { const injector = (mainModuleRef as any)?.injector; + const reuseStrategy = injector?.get?.(NSRouteReuseStrategy, null); + const locationStrategy = injector?.get?.(NSLocationStrategy, null); const router = injector?.get?.(Router, null); + const clearedDetached = reuseStrategy?.clearAllCaches?.() ?? 0; + const clearedLocation = locationStrategy?.resetForHmr?.() ?? null; const cleared = clearAngularHmrRouteConfigCaches(router?.config); - if (cleared > 0) { - console.log('[ng-hmr] cleared Angular route caches before reboot:', cleared); + if ( + clearedDetached > 0 || + cleared > 0 || + (clearedLocation && (clearedLocation.outlets > 0 || clearedLocation.states > 0 || clearedLocation.callbacks > 0 || clearedLocation.hadUrlTree)) + ) { + console.log('[ng-hmr] cleared Angular route caches before reboot:', { + detachedViews: clearedDetached, + locationState: clearedLocation, + routeFields: cleared, + }); } } catch {} }; @@ -299,7 +330,7 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { if (NativeScriptDebug.isLogEnabled()) { NativeScriptDebug.bootstrapLog(`Setting RootView to ${ref}`); } - if (options.embedded) { + if (currentOptions.embedded) { Application.run({ create: () => ref }); } else if (launchEventDone) { rootTransitionGuard.runApplicationResetRootView(Application, () => ref, ref?.constructor?.name || 'View'); @@ -317,11 +348,11 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { 'newRoot:', newRoot?.constructor?.name, ); - console.log('[ng-hmr] setRootView: launchEventDone:', launchEventDone, 'embedded:', options.embedded); + console.log('[ng-hmr] setRootView: launchEventDone:', launchEventDone, 'embedded:', currentOptions.embedded); if (NativeScriptDebug.isLogEnabled()) { NativeScriptDebug.bootstrapLog(`Setting RootView to ${newRoot}`); } - if (options.embedded) { + if (currentOptions.embedded) { console.log('[ng-hmr] setRootView: calling Application.run (embedded)'); Application.run({ create: () => newRoot }); } else if (launchEventDone) { @@ -372,7 +403,7 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { }; runSynchronously( () => - options.appModuleBootstrap(reason).then( + currentOptions.appModuleBootstrap(reason).then( (ref) => { console.log('[ng-hmr] appModuleBootstrap resolved, ref:', ref?.constructor?.name); console.log('[ng-hmr] currentBootstrapId:', currentBootstrapId, 'bootstrapId:', bootstrapId); @@ -482,9 +513,9 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { return; } if (!bootstrapped) { - if (options.loadingModule) { + if (currentOptions.loadingModule) { runSynchronously(() => - options.loadingModule(reason).then( + currentOptions.loadingModule(reason).then( (loadingRef) => { if (currentBootstrapId !== bootstrapId) { // this module is old and not needed anymore @@ -536,8 +567,8 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { }, ), ); - } else if (options.launchView) { - let launchView = options.launchView(reason); + } else if (currentOptions.launchView) { + let launchView = currentOptions.launchView(reason); setRootView(launchView); if (launchView.startAnimation) { setTimeout(() => { @@ -606,7 +637,7 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { global.NativeScriptGlobals.events.addEventListener = global.NativeScriptGlobals.events[Zone.__symbol__('addEventListener')]; } - if (!options.embedded) { + if (!currentOptions.embedded) { Application.on(Application.launchEvent, launchCallback); } Application.on(Application.exitEvent, exitCallback); @@ -681,7 +712,7 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { return; } - if (options.embedded) { + if (currentOptions.embedded) { bootstrapRoot('applaunch'); } else { Application.run(); diff --git a/packages/angular/src/lib/hmr-compiled-components-core.spec.ts b/packages/angular/src/lib/hmr-compiled-components-core.spec.ts index f992181c..9df0d81d 100644 --- a/packages/angular/src/lib/hmr-compiled-components-core.spec.ts +++ b/packages/angular/src/lib/hmr-compiled-components-core.spec.ts @@ -2,6 +2,7 @@ import { getAngularCoreForHmrReset, rememberAngularCoreForHmr, resetAngularHmrCompiledComponents, + setAngularCoreForHmr, } from './hmr-compiled-components-core'; describe('Angular HMR compiled component reset', () => { @@ -57,4 +58,19 @@ describe('Angular HMR compiled component reset', () => { expect(rememberAngularCoreForHmr(replacementCore, globalObj)).toBe(originalCore); expect(globalObj.__NS_ANGULAR_CORE__).toBe(originalCore); }); + + it('allows the active Angular core realm to be updated explicitly for HMR resets', () => { + const originalCore = { + ɵresetCompiledComponents: jest.fn(), + }; + const replacementCore = { + ɵresetCompiledComponents: jest.fn(), + }; + const globalObj: any = { + __NS_ANGULAR_CORE__: originalCore, + }; + + expect(setAngularCoreForHmr(replacementCore, globalObj)).toBe(replacementCore); + expect(globalObj.__NS_ANGULAR_CORE__).toBe(replacementCore); + }); }); \ No newline at end of file diff --git a/packages/angular/src/lib/hmr-compiled-components-core.ts b/packages/angular/src/lib/hmr-compiled-components-core.ts index 1a29508c..85daf7a1 100644 --- a/packages/angular/src/lib/hmr-compiled-components-core.ts +++ b/packages/angular/src/lib/hmr-compiled-components-core.ts @@ -6,6 +6,17 @@ type AngularCoreHolder = { __NS_ANGULAR_CORE__?: AngularCoreWithCompiledComponentReset | null; }; +export function setAngularCoreForHmr( + core: AngularCoreWithCompiledComponentReset | null | undefined, + globalObj: AngularCoreHolder = globalThis as AngularCoreHolder, +): AngularCoreWithCompiledComponentReset | null | undefined { + if (core) { + globalObj.__NS_ANGULAR_CORE__ = core; + } + + return getAngularCoreForHmrReset(core, globalObj); +} + export function getAngularCoreForHmrReset( core: AngularCoreWithCompiledComponentReset | null | undefined, globalObj: AngularCoreHolder = globalThis as AngularCoreHolder, diff --git a/packages/angular/src/lib/legacy/router/hmr-route-bootstrap-core.ts b/packages/angular/src/lib/legacy/router/hmr-route-bootstrap-core.ts new file mode 100644 index 00000000..3a3bd66f --- /dev/null +++ b/packages/angular/src/lib/legacy/router/hmr-route-bootstrap-core.ts @@ -0,0 +1,55 @@ +type AngularBootstrapRouteLike = { + children?: AngularBootstrapRouteLike[]; +}; + +function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== 'object') { + return false; + } + + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function shouldStripRouteKey(key: string): boolean { + return key.startsWith('_') || key.startsWith('ɵ'); +} + +function cloneRouteValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.slice(); + } + + if (isPlainObject(value)) { + return { ...value }; + } + + return value; +} + +function cloneBootstrapRoute(route: T): T { + const next: AngularBootstrapRouteLike = {}; + + for (const [key, value] of Object.entries(route as Record)) { + if (shouldStripRouteKey(key)) { + continue; + } + + if (key === 'children' && Array.isArray(value)) { + next.children = cloneRoutesForBootstrap(value); + continue; + } + + next[key] = cloneRouteValue(value); + } + + return next as T; +} + +export function cloneRoutesForBootstrap(routes: T[] | undefined | null): T[] { + if (!Array.isArray(routes)) { + return []; + } + + return routes.map((route) => cloneBootstrapRoute(route)); +} \ No newline at end of file diff --git a/packages/angular/src/lib/legacy/router/hmr-route-bootstrap.spec.ts b/packages/angular/src/lib/legacy/router/hmr-route-bootstrap.spec.ts new file mode 100644 index 00000000..d6ff56ad --- /dev/null +++ b/packages/angular/src/lib/legacy/router/hmr-route-bootstrap.spec.ts @@ -0,0 +1,58 @@ +import { cloneRoutesForBootstrap } from './hmr-route-bootstrap-core'; + +describe('cloneRoutesForBootstrap', () => { + it('drops private Angular router cache fields while preserving public route config', () => { + const loadComponent = jest.fn(); + const canActivate = [jest.fn()]; + const routes = [ + { + path: 'signup-landing', + loadComponent, + canActivate, + data: { source: 'signup' }, + _loadedComponent: { stale: true }, + _loadedInjector: { stale: true }, + _loadedRoutes: [{ stale: true }], + _injector: { stale: true }, + _loadedNgModuleFactory: { stale: true }, + ɵrouterPageId: 'stale', + children: [ + { + path: 'child', + loadChildren: jest.fn(), + _loadedComponent: { nested: true }, + }, + ], + }, + ] as any; + + const cloned = cloneRoutesForBootstrap(routes); + + expect(cloned).not.toBe(routes); + expect(cloned[0]).not.toBe(routes[0]); + expect(cloned[0].loadComponent).toBe(loadComponent); + expect(cloned[0].canActivate).toEqual(canActivate); + expect(cloned[0].canActivate).not.toBe(canActivate); + expect(cloned[0].data).toEqual({ source: 'signup' }); + expect(cloned[0].data).not.toBe(routes[0].data); + expect(cloned[0]._loadedComponent).toBeUndefined(); + expect(cloned[0]._loadedInjector).toBeUndefined(); + expect(cloned[0]._loadedRoutes).toBeUndefined(); + expect(cloned[0]._injector).toBeUndefined(); + expect(cloned[0]._loadedNgModuleFactory).toBeUndefined(); + expect(cloned[0]['ɵrouterPageId']).toBeUndefined(); + expect(cloned[0].children).toEqual([ + { + path: 'child', + loadChildren: routes[0].children[0].loadChildren, + }, + ]); + expect(cloned[0].children).not.toBe(routes[0].children); + expect(cloned[0].children[0]).not.toBe(routes[0].children[0]); + }); + + it('returns an empty array when routes are missing', () => { + expect(cloneRoutesForBootstrap(undefined)).toEqual([]); + expect(cloneRoutesForBootstrap(null)).toEqual([]); + }); +}); \ No newline at end of file diff --git a/packages/angular/src/lib/legacy/router/hmr-route-cache-core.ts b/packages/angular/src/lib/legacy/router/hmr-route-cache-core.ts index c456b9f4..b7bde7b2 100644 --- a/packages/angular/src/lib/legacy/router/hmr-route-cache-core.ts +++ b/packages/angular/src/lib/legacy/router/hmr-route-cache-core.ts @@ -3,16 +3,34 @@ type AngularHmrRouteLike = { _injector?: unknown; _loadedComponent?: unknown; _loadedInjector?: unknown; + _loadedNgModuleFactory?: unknown; _loadedRoutes?: AngularHmrRouteLike[]; }; -const ROUTE_CACHE_KEYS = ['_loadedComponent', '_loadedInjector', '_loadedRoutes', '_injector'] as const; +const ROUTE_CACHE_KEYS = ['_loadedComponent', '_loadedInjector', '_loadedNgModuleFactory', '_loadedRoutes', '_injector'] as const; + +function destroyRouteCacheValue(value: unknown): void { + if (!value || typeof value !== 'object') { + return; + } + + const destroy = (value as { destroy?: () => void }).destroy; + if (typeof destroy === 'function') { + try { + destroy.call(value); + } catch {} + } +} function clearRouteCacheField(route: Record, key: (typeof ROUTE_CACHE_KEYS)[number]): boolean { if (!Object.prototype.hasOwnProperty.call(route, key) && route[key] === undefined) { return false; } + if (key === '_injector' || key === '_loadedInjector' || key === '_loadedNgModuleFactory') { + destroyRouteCacheValue(route[key]); + } + try { delete route[key]; } catch { diff --git a/packages/angular/src/lib/legacy/router/hmr-route-cache.spec.ts b/packages/angular/src/lib/legacy/router/hmr-route-cache.spec.ts index c1936f50..2586c8b8 100644 --- a/packages/angular/src/lib/legacy/router/hmr-route-cache.spec.ts +++ b/packages/angular/src/lib/legacy/router/hmr-route-cache.spec.ts @@ -2,17 +2,22 @@ import { clearAngularHmrRouteConfigCaches } from './hmr-route-cache-core'; describe('Angular HMR route cache clearing', () => { it('clears lazy route caches recursively while preserving public route fields', () => { + const detailsInjectorDestroy = jest.fn(); + const surveyInjectorDestroy = jest.fn(); + const childInjectorDestroy = jest.fn(); + const loadedFactoryDestroy = jest.fn(); const grandchild = { path: 'details', _loadedComponent: { name: 'DetailsComponent' }, - _loadedInjector: { token: 'details' }, + _loadedInjector: { token: 'details', destroy: detailsInjectorDestroy }, }; const child = { path: 'survey', children: [grandchild], _loadedComponent: { name: 'SurveyComponent' }, - _loadedInjector: { token: 'survey' }, - _injector: { token: 'child-injector' }, + _loadedInjector: { token: 'survey', destroy: surveyInjectorDestroy }, + _loadedNgModuleFactory: { token: 'survey-factory', destroy: loadedFactoryDestroy }, + _injector: { token: 'child-injector', destroy: childInjectorDestroy }, }; const route = { path: 'onboarding-flow', @@ -33,13 +38,18 @@ describe('Angular HMR route cache clearing', () => { const cleared = clearAngularHmrRouteConfigCaches([route]); - expect(cleared).toBe(9); + expect(cleared).toBe(10); expect(route.path).toBe('onboarding-flow'); expect(child.path).toBe('survey'); expect(grandchild.path).toBe('details'); + expect(detailsInjectorDestroy).toHaveBeenCalledTimes(1); + expect(surveyInjectorDestroy).toHaveBeenCalledTimes(1); + expect(childInjectorDestroy).toHaveBeenCalledTimes(1); + expect(loadedFactoryDestroy).toHaveBeenCalledTimes(1); expect((route as any)._loadedRoutes).toBeUndefined(); expect((child as any)._loadedComponent).toBeUndefined(); expect((child as any)._loadedInjector).toBeUndefined(); + expect((child as any)._loadedNgModuleFactory).toBeUndefined(); expect((child as any)._injector).toBeUndefined(); expect((grandchild as any)._loadedComponent).toBeUndefined(); expect((grandchild as any)._loadedInjector).toBeUndefined(); diff --git a/packages/angular/src/lib/legacy/router/ns-location-strategy.spec.ts b/packages/angular/src/lib/legacy/router/ns-location-strategy.spec.ts new file mode 100644 index 00000000..48e4419e --- /dev/null +++ b/packages/angular/src/lib/legacy/router/ns-location-strategy.spec.ts @@ -0,0 +1,81 @@ +jest.mock('@angular/common', () => ({ + LocationStrategy: class {}, +})); + +jest.mock('@angular/core', () => ({ + Inject: () => () => undefined, + Injectable: () => (target: unknown) => target, + Optional: () => () => undefined, +})); + +jest.mock('@angular/router', () => ({ + DefaultUrlSerializer: class {}, +})); + +jest.mock('@nativescript/core', () => ({ + Frame: class {}, +})); + +jest.mock('../../trace', () => ({ + NativeScriptDebug: { + isLogEnabled: () => false, + routerLog: jest.fn(), + }, +})); + +jest.mock('../../tokens', () => ({ + START_PATH: Symbol('START_PATH'), +})); + +jest.mock('../frame.service', () => ({ + FrameService: class { + getFrame() { + return null; + } + }, +})); + +import { NSLocationStrategy } from './ns-location-strategy'; + +describe('NSLocationStrategy', () => { + it('clears preserved outlet state during HMR reset', () => { + const strategy = new NSLocationStrategy({ getFrame: () => null } as any, '/signup-landing'); + + (strategy as any).outlets = [{ states: [{}, {}] }, { states: [{}] }]; + (strategy as any).currentOutlet = { id: 'primary' }; + (strategy as any).currentUrlTree = { root: {} }; + (strategy as any).popStateCallbacks = [jest.fn(), jest.fn()]; + (strategy as any)._modalNavigationDepth = 2; + + expect(strategy.resetForHmr()).toEqual({ + outlets: 2, + states: 3, + callbacks: 2, + hadUrlTree: true, + }); + expect((strategy as any).outlets).toEqual([]); + expect((strategy as any).currentOutlet).toBeNull(); + expect((strategy as any).currentUrlTree).toBeNull(); + expect((strategy as any).popStateCallbacks).toEqual([]); + expect((strategy as any)._modalNavigationDepth).toBe(0); + }); + + it('makes ngOnDestroy idempotently drain location state', () => { + const strategy = new NSLocationStrategy({ getFrame: () => null } as any, '/signup-landing'); + + (strategy as any).outlets = [{ states: [{}] }]; + (strategy as any).currentOutlet = { id: 'primary' }; + (strategy as any).currentUrlTree = { root: {} }; + (strategy as any).popStateCallbacks = [jest.fn()]; + (strategy as any)._modalNavigationDepth = 1; + + strategy.ngOnDestroy(); + strategy.ngOnDestroy(); + + expect((strategy as any).outlets).toEqual([]); + expect((strategy as any).currentOutlet).toBeNull(); + expect((strategy as any).currentUrlTree).toBeNull(); + expect((strategy as any).popStateCallbacks).toEqual([]); + expect((strategy as any)._modalNavigationDepth).toBe(0); + }); +}); \ No newline at end of file diff --git a/packages/angular/src/lib/legacy/router/ns-location-strategy.ts b/packages/angular/src/lib/legacy/router/ns-location-strategy.ts index 07edcf87..95b2066c 100644 --- a/packages/angular/src/lib/legacy/router/ns-location-strategy.ts +++ b/packages/angular/src/lib/legacy/router/ns-location-strategy.ts @@ -33,6 +33,26 @@ export class NSLocationStrategy extends LocationStrategy implements OnDestroy { return this.currentOutlet && this.currentOutlet.peekState(); } + resetForHmr() { + const outletCount = this.outlets.length; + const stateCount = this.outlets.reduce((total, outlet) => total + outlet.states.length, 0); + const callbackCount = this.popStateCallbacks.length; + const hadUrlTree = !!this.currentUrlTree; + + this.outlets = []; + this.currentOutlet = null; + this.currentUrlTree = null; + this.popStateCallbacks = []; + this._modalNavigationDepth = 0; + + return { + outlets: outletCount, + states: stateCount, + callbacks: callbackCount, + hadUrlTree, + }; + } + path(): string { if (!this.currentUrlTree) { return this.startPath || '/'; @@ -683,7 +703,6 @@ export class NSLocationStrategy extends LocationStrategy implements OnDestroy { NativeScriptDebug.routerLog('NSLocationStrategy.ngOnDestroy()'); } - this.outlets = []; - this.currentOutlet = null; + this.resetForHmr(); } } diff --git a/packages/angular/src/lib/legacy/router/ns-route-reuse-strategy.spec.ts b/packages/angular/src/lib/legacy/router/ns-route-reuse-strategy.spec.ts new file mode 100644 index 00000000..db861bb5 --- /dev/null +++ b/packages/angular/src/lib/legacy/router/ns-route-reuse-strategy.spec.ts @@ -0,0 +1,57 @@ +jest.mock('@angular/core', () => ({ + Injectable: () => (target: unknown) => target, +})); + +jest.mock('@angular/router', () => ({})); + +jest.mock('../../trace', () => ({ + NativeScriptDebug: { + isLogEnabled: () => false, + routeReuseStrategyLog: jest.fn(), + }, +})); + +jest.mock('./ns-location-strategy', () => ({ + NSLocationStrategy: class {}, +})); + +jest.mock('./page-router-outlet-utils', () => ({ + destroyComponentRef: jest.fn(), + findTopActivatedRouteNodeForOutlet: (route: unknown) => route, + pageRouterActivatedSymbol: Symbol('page-router-activated'), +})); + +import { NSRouteReuseStrategy } from './ns-route-reuse-strategy'; + +describe('NSRouteReuseStrategy', () => { + it('clears every cached outlet when destroyed', () => { + const primaryClear = jest.fn(); + const secondaryClear = jest.fn(); + const strategy = new NSRouteReuseStrategy({} as any); + + (strategy as any).cacheByOutlet = { + primary: { clear: primaryClear }, + secondary: { clear: secondaryClear }, + }; + + expect(strategy.clearAllCaches()).toBe(2); + expect(primaryClear).toHaveBeenCalledTimes(1); + expect(secondaryClear).toHaveBeenCalledTimes(1); + expect((strategy as any).cacheByOutlet).toEqual({}); + }); + + it('makes ngOnDestroy idempotently drain cached outlets', () => { + const primaryClear = jest.fn(); + const strategy = new NSRouteReuseStrategy({} as any); + + (strategy as any).cacheByOutlet = { + primary: { clear: primaryClear }, + }; + + strategy.ngOnDestroy(); + strategy.ngOnDestroy(); + + expect(primaryClear).toHaveBeenCalledTimes(1); + expect((strategy as any).cacheByOutlet).toEqual({}); + }); +}); \ No newline at end of file diff --git a/packages/angular/src/lib/legacy/router/ns-route-reuse-strategy.ts b/packages/angular/src/lib/legacy/router/ns-route-reuse-strategy.ts index e9c83bff..a887d144 100644 --- a/packages/angular/src/lib/legacy/router/ns-route-reuse-strategy.ts +++ b/packages/angular/src/lib/legacy/router/ns-route-reuse-strategy.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, OnDestroy } from '@angular/core'; import { RouteReuseStrategy, ActivatedRouteSnapshot, DetachedRouteHandle } from '@angular/router'; import { NativeScriptDebug } from '../../trace'; @@ -114,7 +114,7 @@ class DetachedStateCache { * Reuses routes as long as their route config is the same. */ @Injectable() -export class NSRouteReuseStrategy implements RouteReuseStrategy { +export class NSRouteReuseStrategy implements RouteReuseStrategy, OnDestroy { private cacheByOutlet: { [key: string]: DetachedStateCache } = {}; constructor(private location: NSLocationStrategy) {} @@ -329,4 +329,19 @@ export class NSRouteReuseStrategy implements RouteReuseStrategy { cache.clearModalCache(); } } + + clearAllCaches(): number { + const outletKeys = Object.keys(this.cacheByOutlet); + + for (const outletKey of outletKeys) { + this.cacheByOutlet[outletKey]?.clear(); + delete this.cacheByOutlet[outletKey]; + } + + return outletKeys.length; + } + + ngOnDestroy(): void { + this.clearAllCaches(); + } } diff --git a/packages/angular/src/lib/legacy/router/router.module.ts b/packages/angular/src/lib/legacy/router/router.module.ts index f418e737..5894caf0 100644 --- a/packages/angular/src/lib/legacy/router/router.module.ts +++ b/packages/angular/src/lib/legacy/router/router.module.ts @@ -34,6 +34,7 @@ import { FrameService } from '../frame.service'; import { NSEmptyOutletComponent } from './ns-empty-outlet.component'; import { NativeScriptCommonModule } from '../../nativescript-common.module'; import { START_PATH } from '../../tokens'; +import { cloneRoutesForBootstrap } from './hmr-route-bootstrap-core'; import { NativeScriptAngularHmrRouteTracker, readAngularHmrPendingStartPath } from './hmr-route-state'; import { ComponentInputBindingOptions, INPUT_BINDER, RoutedComponentInputBinder } from './router-component-input-binder'; @@ -77,7 +78,7 @@ export class NativeScriptRouterModule { return { ngModule: NativeScriptRouterModule, providers: [ - ...RouterModule.forRoot(routes, config).providers, + ...RouterModule.forRoot(cloneRoutesForBootstrap(routes), config).providers, { provide: START_PATH, useFactory: readAngularHmrPendingStartPath, @@ -108,7 +109,7 @@ export class NativeScriptRouterModule { } static forChild(routes: Routes): ModuleWithProviders { - return { ngModule: NativeScriptRouterModule, providers: RouterModule.forChild(routes).providers }; + return { ngModule: NativeScriptRouterModule, providers: RouterModule.forChild(cloneRoutesForBootstrap(routes)).providers }; } } export function rootRoute(router: Router): ActivatedRoute { @@ -118,7 +119,7 @@ export function rootRoute(router: Router): ActivatedRoute { export function provideNativeScriptRouter(routes: Routes, ...features: RouterFeatures[]) { const hasInputBinding = features.some((f: any) => f.ɵkind === COMPONENT_INPUT_BINDING_FEATURE_KIND); return makeEnvironmentProviders([ - provideRouter(routes, ...features), + provideRouter(cloneRoutesForBootstrap(routes), ...features), { provide: START_PATH, useFactory: readAngularHmrPendingStartPath, From a1992eea2f5960da154a61a6363b0958f84fa09e Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 25 Apr 2026 11:38:39 -0700 Subject: [PATCH 09/19] feat: hmr logs gated by hmrTraceCategory --- packages/angular/src/lib/application.ts | 176 ++++++++++-------- .../angular/src/lib/platform-nativescript.ts | 2 +- .../src/lib/router/platform-location.ts | 7 +- packages/angular/src/lib/trace.ts | 9 + packages/angular/src/lib/view-util.ts | 17 +- 5 files changed, 125 insertions(+), 86 deletions(-) diff --git a/packages/angular/src/lib/application.ts b/packages/angular/src/lib/application.ts index 5186e769..00201b2d 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -272,11 +272,9 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { cleared > 0 || (clearedLocation && (clearedLocation.outlets > 0 || clearedLocation.states > 0 || clearedLocation.callbacks > 0 || clearedLocation.hadUrlTree)) ) { - console.log('[ng-hmr] cleared Angular route caches before reboot:', { - detachedViews: clearedDetached, - locationState: clearedLocation, - routeFields: cleared, - }); + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`cleared Angular route caches before reboot: detachedViews=${clearedDetached} routeFields=${cleared} locationState=${JSON.stringify(clearedLocation)}`); + } } } catch {} }; @@ -305,20 +303,29 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { }, 0); }; const setRootView = (ref: NgModuleRef | ApplicationRef | View) => { - console.log('[ng-hmr] setRootView called, bootstrapId:', bootstrapId, 'ref type:', ref?.constructor?.name); + const traceEnabled = NativeScriptDebug.isLogEnabled(); + if (traceEnabled) { + NativeScriptDebug.hmrLog(`setRootView called bootstrapId=${bootstrapId} refType=${ref?.constructor?.name}`); + } if (bootstrapId === -1) { - // treat edge cases - console.log('[ng-hmr] setRootView: bootstrapId is -1, returning early'); + // edge case: a stale ref racing with a teardown + if (traceEnabled) { + NativeScriptDebug.hmrLog('setRootView: bootstrapId is -1, returning early'); + } return; } if (ref instanceof NgModuleRef || ref instanceof ApplicationRef) { if (ref.injector.get(DISABLE_ROOT_VIEW_HANDLING, false)) { - console.log('[ng-hmr] setRootView: DISABLE_ROOT_VIEW_HANDLING is true, returning'); + if (traceEnabled) { + NativeScriptDebug.hmrLog('setRootView: DISABLE_ROOT_VIEW_HANDLING is true, returning'); + } return; } } else { if (ref['__disable_root_view_handling']) { - console.log('[ng-hmr] setRootView: __disable_root_view_handling is true, returning'); + if (traceEnabled) { + NativeScriptDebug.hmrLog('setRootView: __disable_root_view_handling is true, returning'); + } return; } } @@ -326,8 +333,8 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { NativeScriptDebug.bootstrapLog(`Setting RootView ${launchEventDone ? 'outside of' : 'during'} launch event`); // TODO: check for leaks when root view isn't properly destroyed if (ref instanceof View) { - console.log('[ng-hmr] setRootView: ref is View, launchEventDone:', launchEventDone); - if (NativeScriptDebug.isLogEnabled()) { + if (traceEnabled) { + NativeScriptDebug.hmrLog(`setRootView: ref is View, launchEventDone=${launchEventDone}`); NativeScriptDebug.bootstrapLog(`Setting RootView to ${ref}`); } if (currentOptions.embedded) { @@ -342,41 +349,36 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { } const view = ref.injector.get(APP_ROOT_VIEW) as AppHostView | View; const newRoot = view instanceof AppHostView ? view.content : view; - console.log( - '[ng-hmr] setRootView: view from injector:', - view?.constructor?.name, - 'newRoot:', - newRoot?.constructor?.name, - ); - console.log('[ng-hmr] setRootView: launchEventDone:', launchEventDone, 'embedded:', currentOptions.embedded); - if (NativeScriptDebug.isLogEnabled()) { + if (traceEnabled) { + NativeScriptDebug.hmrLog(`setRootView: view=${view?.constructor?.name} newRoot=${newRoot?.constructor?.name} launchEventDone=${launchEventDone} embedded=${!!currentOptions.embedded}`); NativeScriptDebug.bootstrapLog(`Setting RootView to ${newRoot}`); } if (currentOptions.embedded) { - console.log('[ng-hmr] setRootView: calling Application.run (embedded)'); + if (traceEnabled) { + NativeScriptDebug.hmrLog('setRootView: calling Application.run (embedded)'); + } Application.run({ create: () => newRoot }); } else if (launchEventDone) { - console.log('[ng-hmr] setRootView: calling Application.resetRootView'); - console.log('[ng-hmr] setRootView: newRoot details:', { - type: newRoot?.constructor?.name, - nativeView: !!newRoot?.nativeView, - parent: newRoot?.parent?.constructor?.name, - childCount: (newRoot as any)?.getChildrenCount?.() ?? 'N/A', - }); + if (traceEnabled) { + NativeScriptDebug.hmrLog( + `setRootView: calling Application.resetRootView newRoot type=${newRoot?.constructor?.name} hasNativeView=${!!newRoot?.nativeView} parent=${newRoot?.parent?.constructor?.name} childCount=${(newRoot as any)?.getChildrenCount?.() ?? 'N/A'}`, + ); + } rootTransitionGuard.runApplicationResetRootView(Application, () => newRoot, newRoot?.constructor?.name || 'View'); refreshRootViewCss(newRoot); - console.log('[ng-hmr] setRootView: Application.resetRootView returned'); - // Check root view after reset - setTimeout(() => { - const currentRoot = Application.getRootView(); - console.log('[ng-hmr] setRootView: after reset, getRootView:', { - type: currentRoot?.constructor?.name, - nativeView: !!currentRoot?.nativeView, - childCount: (currentRoot as any)?.getChildrenCount?.() ?? 'N/A', - }); - }, 100); + if (traceEnabled) { + NativeScriptDebug.hmrLog('setRootView: Application.resetRootView returned'); + setTimeout(() => { + const currentRoot = Application.getRootView(); + NativeScriptDebug.hmrLog( + `setRootView: post-reset getRootView type=${currentRoot?.constructor?.name} hasNativeView=${!!currentRoot?.nativeView} childCount=${(currentRoot as any)?.getChildrenCount?.() ?? 'N/A'}`, + ); + }, 100); + } } else { - console.log('[ng-hmr] setRootView: setting targetRootView (launch in progress)'); + if (traceEnabled) { + NativeScriptDebug.hmrLog('setRootView: setting targetRootView (launch in progress)'); + } targetRootView = newRoot; } }; @@ -388,14 +390,18 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { setRootView(errorTextBox); }; const bootstrapRoot = (reason: NgModuleReason) => { - console.log('[ng-hmr] bootstrapRoot called, reason:', reason); + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`bootstrapRoot called reason=${reason}`); + } try { if (reason === 'hotreload') { resetAngularHmrCompiledComponents(getAngularCoreForHmrReset(AngularCore as any, globalThis as any)); } bootstrapId = Date.now(); - console.log('[ng-hmr] bootstrapRoot: new bootstrapId:', bootstrapId); + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`bootstrapRoot: new bootstrapId=${bootstrapId}`); + } const currentBootstrapId = bootstrapId; let bootstrapped = false; let onMainBootstrap = () => { @@ -405,12 +411,19 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { () => currentOptions.appModuleBootstrap(reason).then( (ref) => { - console.log('[ng-hmr] appModuleBootstrap resolved, ref:', ref?.constructor?.name); - console.log('[ng-hmr] currentBootstrapId:', currentBootstrapId, 'bootstrapId:', bootstrapId); + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog( + `appModuleBootstrap resolved ref=${ref?.constructor?.name} currentBootstrapId=${currentBootstrapId} bootstrapId=${bootstrapId}`, + ); + } if (currentBootstrapId !== bootstrapId) { - // this module is old and not needed anymore - // this may happen when developer uses async app initializer and the user exits the app before this bootstraps - console.log('[ng-hmr] bootstrap ID mismatch, destroying ref'); + // The pending bootstrap resolved AFTER another reboot bumped + // bootstrapId. This typically happens when a developer ships + // an async APP_INITIALIZER and the user exits/re-enters the + // app while it's still resolving. Drop this ref. + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog('bootstrap ID mismatch, destroying ref'); + } ref.destroy(); return; } @@ -433,37 +446,40 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { runInZone(() => { mainModuleRef = ref; - // Expose ApplicationRef for HMR to trigger change detection - // Check for ApplicationRef by duck-typing since instanceof can fail across module realms + // Expose ApplicationRef for HMR to trigger change detection. + // Check by duck-typing because `instanceof` can fail across + // module realms during HMR — we may be holding a fresh + // ApplicationRef class while `ref` was constructed by an + // earlier (now-evicted) realm copy. const refAny = ref as any; const isAppRef = refAny && typeof refAny.tick === 'function' && Array.isArray(refAny.components); - console.log( - '[ng-hmr] ref type check: isAppRef=', - isAppRef, - 'has tick=', - typeof refAny?.tick === 'function', - 'has components=', - Array.isArray(refAny?.components), - ); + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog( + `ref type check isAppRef=${isAppRef} hasTick=${typeof refAny?.tick === 'function'} hasComponents=${Array.isArray(refAny?.components)}`, + ); + } if (isAppRef) { global['__NS_ANGULAR_APP_REF__'] = ref; - // Mark boot complete for the HMR system global['__NS_HMR_BOOT_COMPLETE__'] = true; - // Register bootstrapped components for HMR lookup if (!global['__NS_ANGULAR_COMPONENTS__']) { global['__NS_ANGULAR_COMPONENTS__'] = {}; } - // Get the component class from the first bootstrapped component - console.log('[ng-hmr] ApplicationRef components count:', refAny.components?.length); + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`ApplicationRef components count=${refAny.components?.length ?? 0}`); + } if (refAny.components && refAny.components.length > 0) { const componentRef = refAny.components[0]; - console.log('[ng-hmr] componentRef:', componentRef?.constructor?.name); - console.log('[ng-hmr] componentRef.componentType:', componentRef?.componentType?.name); + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog( + `componentRef=${componentRef?.constructor?.name} componentType=${componentRef?.componentType?.name}`, + ); + } - // For Angular 17+ standalone components, the component type is on componentRef.componentType - // For older Angular, try componentRef.instance.constructor + // Angular 17+ standalone: the component class is on + // `componentRef.componentType`. Older Angular keeps it on + // `componentRef.instance.constructor`. let componentType = componentRef?.componentType; if (!componentType && componentRef?.instance) { componentType = componentRef.instance.constructor; @@ -471,12 +487,14 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { if (componentType && componentType.name) { global['__NS_ANGULAR_COMPONENTS__'][componentType.name] = componentType; - console.log('[ng-hmr] Registered component for HMR:', componentType.name); - } else { - console.log('[ng-hmr] Could not get componentType name'); + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`registered component for HMR: ${componentType.name}`); + } + } else if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog('could not resolve componentType name'); } - } else { - console.log('[ng-hmr] No components in ApplicationRef'); + } else if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog('no components in ApplicationRef'); } } else { const appRef = ref.injector.get(ApplicationRef, null); @@ -672,19 +690,29 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { disposePlatform('hotreload'); }; global['__reboot_ng_modules__'] = (shouldDisposePlatform: boolean = false) => { - console.log('[ng-hmr] __reboot_ng_modules__ called, shouldDisposePlatform:', shouldDisposePlatform); - console.log('[ng-hmr] current bootstrapId:', bootstrapId, 'mainModuleRef:', !!mainModuleRef); + const traceEnabled = NativeScriptDebug.isLogEnabled(); + if (traceEnabled) { + NativeScriptDebug.hmrLog( + `__reboot_ng_modules__ called shouldDisposePlatform=${shouldDisposePlatform} bootstrapId=${bootstrapId} hasMainModuleRef=${!!mainModuleRef}`, + ); + } try { global['__NS_CAPTURE_ANGULAR_HMR_ROUTE__']?.(); } catch {} disposeLastModules('hotreload'); - console.log('[ng-hmr] after disposeLastModules, bootstrapId:', bootstrapId); + if (traceEnabled) { + NativeScriptDebug.hmrLog(`after disposeLastModules bootstrapId=${bootstrapId}`); + } if (shouldDisposePlatform) { disposePlatform('hotreload'); } - console.log('[ng-hmr] calling bootstrapRoot...'); + if (traceEnabled) { + NativeScriptDebug.hmrLog('calling bootstrapRoot'); + } bootstrapRoot('hotreload'); - console.log('[ng-hmr] bootstrapRoot returned, new bootstrapId:', bootstrapId); + if (traceEnabled) { + NativeScriptDebug.hmrLog(`bootstrapRoot returned bootstrapId=${bootstrapId}`); + } }; if (isWebpackHot) { diff --git a/packages/angular/src/lib/platform-nativescript.ts b/packages/angular/src/lib/platform-nativescript.ts index 23a0541a..cabef5e6 100644 --- a/packages/angular/src/lib/platform-nativescript.ts +++ b/packages/angular/src/lib/platform-nativescript.ts @@ -260,7 +260,7 @@ export interface AppOptions { * @deprecated use runNativeScriptAngularApp instead */ export const platformNativeScriptDynamic = function (options?: AppOptions, extraProviders?: StaticProvider[]) { - console.log('platformNativeScriptDynamic is deprecated, use runNativeScriptAngularApp instead'); + console.warn('platformNativeScriptDynamic is deprecated, use runNativeScriptAngularApp instead'); options = options || {}; extraProviders = extraProviders || []; diff --git a/packages/angular/src/lib/router/platform-location.ts b/packages/angular/src/lib/router/platform-location.ts index 8d63ae07..dba41d1e 100644 --- a/packages/angular/src/lib/router/platform-location.ts +++ b/packages/angular/src/lib/router/platform-location.ts @@ -10,9 +10,8 @@ export class NativescriptPlatformLocation extends PlatformLocation { constructor(@Inject(START_PATH) private startPath: any) { super(); if (NativeScriptDebug.enabled) { - NativeScriptDebug.routerLog('NativescriptPlatformLocation.constructor'); + NativeScriptDebug.routerLog(`NativescriptPlatformLocation.constructor startPath=${startPath}`); } - console.log(startPath); if (this.startPath) { if (this.startPath instanceof Promise) { this.startPath.then((v) => (this._pathname = this._pathname === undefined ? v : this._pathname)); @@ -68,7 +67,9 @@ export class NativescriptPlatformLocation extends PlatformLocation { if (this._pathname === undefined) { this._pathname = ''; } - console.log('pathname', this._pathname); + if (NativeScriptDebug.enabled) { + NativeScriptDebug.routerLog(`NativescriptPlatformLocation.pathname ${this._pathname}`); + } return this._pathname; } get search(): string { diff --git a/packages/angular/src/lib/trace.ts b/packages/angular/src/lib/trace.ts index c23ce83c..e2824ac7 100644 --- a/packages/angular/src/lib/trace.ts +++ b/packages/angular/src/lib/trace.ts @@ -8,6 +8,7 @@ export class NativeScriptDebug { static readonly routeReuseStrategyTraceCategory = 'ns-route-reuse-strategy'; static readonly listViewTraceCategory = 'ns-list-view'; static readonly bootstrapCategory = 'bootstrap'; + static readonly hmrTraceCategory = 'ns-ng-hmr'; // TODO: migrate all usage to this - avoids extraneous method executions static readonly enabled = Trace.isEnabled(); @@ -62,4 +63,12 @@ export class NativeScriptDebug { static bootstrapLogError(message: string): void { Trace.write(message, NativeScriptDebug.bootstrapCategory, Trace.messageType.error); } + + static hmrLog(message: string): void { + Trace.write(message, NativeScriptDebug.hmrTraceCategory); + } + + static hmrLogError(message: string): void { + Trace.write(message, NativeScriptDebug.hmrTraceCategory, Trace.messageType.error); + } } diff --git a/packages/angular/src/lib/view-util.ts b/packages/angular/src/lib/view-util.ts index 85e77deb..e6819cc9 100644 --- a/packages/angular/src/lib/view-util.ts +++ b/packages/angular/src/lib/view-util.ts @@ -36,15 +36,15 @@ function printNgTree(view: NgView) { } function printChildrenRecurse(parent: NgView) { const children = parent.firstChild ? [parent.firstChild, ...getChildrenSiblings(parent.firstChild).nextSiblings] : []; - console.log( - `parent: ${parent}, firstChild: ${parent.firstChild}, lastChild: ${parent.lastChild} children: ${children}`, - ); - if (parent.firstChild) { - console.log(`----- start ${parent}`); + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.viewUtilLog(`parent: ${parent}, firstChild: ${parent.firstChild}, lastChild: ${parent.lastChild} children: ${children}`); + if (parent.firstChild) { + NativeScriptDebug.viewUtilLog(`----- start ${parent}`); + } } children.forEach((c) => printChildrenRecurse(c)); - if (parent.firstChild) { - console.log(`----- end ${parent}`); + if (parent.firstChild && NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.viewUtilLog(`----- end ${parent}`); } } @@ -68,8 +68,9 @@ function getChildrenSiblings(view: NgView) { } function printSiblingsTree(view: NgView) { + if (!NativeScriptDebug.isLogEnabled()) return; const { previousSiblings, nextSiblings } = getChildrenSiblings(view); - console.log(`${view} previousSiblings: ${previousSiblings} nextSiblings: ${nextSiblings}`); + NativeScriptDebug.viewUtilLog(`${view} previousSiblings: ${previousSiblings} nextSiblings: ${nextSiblings}`); } // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type From e2f845da2e0c0d8dd736d52352882f23e50c4245 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 26 Apr 2026 09:57:24 -0700 Subject: [PATCH 10/19] feat: hmr handling with modals and route preservation --- packages/angular/src/lib/application.ts | 65 ++- .../src/lib/cdk/dialog/dialog-config.ts | 17 + .../cdk/dialog/dialog-hmr-animation.spec.ts | 123 ++++ .../lib/cdk/dialog/dialog-hmr-animation.ts | 62 ++ .../src/lib/cdk/dialog/dialog-hmr.spec.ts | 144 +++++ .../angular/src/lib/cdk/dialog/dialog-hmr.ts | 182 ++++++ .../src/lib/cdk/dialog/dialog-module.ts | 28 +- .../src/lib/cdk/dialog/dialog-services.ts | 538 +++++++++++++++++- .../src/lib/hmr-class-registry.spec.ts | 240 ++++++++ .../angular/src/lib/hmr-class-registry.ts | 333 +++++++++++ .../src/lib/hmr-eager-services.spec.ts | 155 +++++ .../angular/src/lib/hmr-eager-services.ts | 150 +++++ .../angular/src/lib/hmr-environment.spec.ts | 181 ++++++ packages/angular/src/lib/hmr-environment.ts | 98 ++++ .../legacy/router/hmr-route-bootstrap.spec.ts | 2 +- .../legacy/router/hmr-route-replay.spec.ts | 259 +++++++++ .../src/lib/legacy/router/hmr-route-replay.ts | 197 +++++++ .../lib/legacy/router/hmr-route-state-core.ts | 260 ++++++++- .../router/hmr-route-state-tracker.spec.ts | 197 +++++++ .../lib/legacy/router/hmr-route-state.spec.ts | 192 +++++++ .../src/lib/legacy/router/hmr-route-state.ts | 87 ++- .../angular/src/lib/legacy/router/index.ts | 6 + .../src/lib/legacy/router/router.module.ts | 6 +- 23 files changed, 3504 insertions(+), 18 deletions(-) create mode 100644 packages/angular/src/lib/cdk/dialog/dialog-hmr-animation.spec.ts create mode 100644 packages/angular/src/lib/cdk/dialog/dialog-hmr-animation.ts create mode 100644 packages/angular/src/lib/cdk/dialog/dialog-hmr.spec.ts create mode 100644 packages/angular/src/lib/cdk/dialog/dialog-hmr.ts create mode 100644 packages/angular/src/lib/hmr-class-registry.spec.ts create mode 100644 packages/angular/src/lib/hmr-class-registry.ts create mode 100644 packages/angular/src/lib/hmr-eager-services.spec.ts create mode 100644 packages/angular/src/lib/hmr-eager-services.ts create mode 100644 packages/angular/src/lib/hmr-environment.spec.ts create mode 100644 packages/angular/src/lib/hmr-environment.ts create mode 100644 packages/angular/src/lib/legacy/router/hmr-route-replay.spec.ts create mode 100644 packages/angular/src/lib/legacy/router/hmr-route-replay.ts create mode 100644 packages/angular/src/lib/legacy/router/hmr-route-state-tracker.spec.ts diff --git a/packages/angular/src/lib/application.ts b/packages/angular/src/lib/application.ts index 00201b2d..4e787806 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -13,7 +13,7 @@ import { Utils, View, } from '@nativescript/core'; -import { Observable, Subject } from 'rxjs'; +import { Observable, ReplaySubject, Subject } from 'rxjs'; import { filter, map, take } from 'rxjs/operators'; import { AppHostView } from './app-host-view'; import { @@ -22,6 +22,8 @@ import { resetAngularHmrCompiledComponents, setAngularCoreForHmr, } from './hmr-compiled-components-core'; +import { _hmrDiagBumpCycle, installAngularHmrComponentRegistrar } from './hmr-class-registry'; +import { installHmrEagerRegistrar, runHmrEagerInstantiators } from './hmr-eager-services'; import { NativeScriptLoadingService } from './loading.service'; import { clearAngularHmrRouteConfigCaches } from './legacy/router/hmr-route-cache-core'; import { NSLocationStrategy } from './legacy/router/ns-location-strategy'; @@ -35,8 +37,30 @@ import { NativeScriptDebug } from './trace'; // We need to use the original one that has the registered LViews rememberAngularCoreForHmr(AngularCore as any, globalThis as any); +// Install the cross-module HMR component registrar. The Vite plugin +// `ns-component-hmr-register` injects a call to the global hook +// `__NS_HMR_REGISTER_COMPONENT__` at the end of every user `.ts` file +// that declares an `@Component`-decorated class. After an HMR reboot, +// each re-evaluated module pushes its fresh class into the registry, +// and HMR helpers (modal restore, route replay) read the registry via +// `getFreshComponentClass` to re-attach to the *live* class instead of +// a captured stale reference. Production short-circuits inside the +// helper (the hook is never assigned). +// +// We install the registrar before any other module initialization can +// reference the hook so a user module loaded synchronously alongside +// `@nativescript/angular` always finds the function present. +installAngularHmrComponentRegistrar(); + +// Install the cross-module registration entry point used by HMR-aware +// services (e.g. `NativeDialog`) to ask for eager construction after +// every bootstrap. Idempotent: re-evaluations after HMR are no-ops. +installHmrEagerRegistrar(); + const angularHmrGlobal = globalThis as any; -angularHmrGlobal.__NS_REMEMBER_ANGULAR_CORE__ = (core: any) => setAngularCoreForHmr(core, angularHmrGlobal); +angularHmrGlobal.__NS_REMEMBER_ANGULAR_CORE__ = (core: any) => { + setAngularCoreForHmr(core, angularHmrGlobal); +}; export interface AppLaunchView extends LayoutBase { // called when the animation is to begin @@ -68,7 +92,17 @@ export type NgModuleEvent = }; export const preAngularDisposal$ = new Subject(); -export const postAngularBootstrap$ = new Subject(); +/** + * Stream that emits when an Angular module finishes bootstrapping. Modeled + * as a `ReplaySubject(1)` so consumers (e.g. `NativeDialog`) instantiated + * lazily — *after* the bootstrap event has already fired — still receive + * the latest event and can react. Without buffering, a service that the + * user app injects on first need (after bootstrap) would silently miss the + * `hotreload` notification and skip HMR-only work like restoring captured + * modal state. The buffer size of 1 means each new HMR cycle replaces the + * cached event so cycle N's late subscribers don't see cycle N-1's event. + */ +export const postAngularBootstrap$ = new ReplaySubject(1); /** * @deprecated @@ -127,17 +161,33 @@ function emitModuleBootstrapEvent( name: 'main' | 'loading', reason: NgModuleReason, ) { + console.info(`[ns-hmr-diag][application] emitModuleBootstrapEvent name=${name} reason=${reason}`); + // Instantiate registered HMR-aware services *before* emitting so they + // attach their subscriptions in the same JS task and are guaranteed to + // observe the event being emitted. `postAngularBootstrap$` is also a + // `ReplaySubject(1)`, so a service injected later still receives the + // buffered event — the eager pass is the fast path that lets the + // restore work begin in the same task as bootstrap completion. + if (name === 'main') { + runHmrEagerInstantiators( + (ref as ApplicationRef | NgModuleRef).injector, + (err) => NativeScriptDebug.bootstrapLogError(`HMR eager instantiator threw: ${(err as Error)?.message ?? err}`), + ); + } postAngularBootstrap$.next({ moduleType: name, reference: ref, reason, }); + console.info(`[ns-hmr-diag][application] postAngularBootstrap$.next() emitted name=${name} reason=${reason}`); } function destroyRef(ref: NgModuleRef | ApplicationRef, name: 'main' | 'loading', reason: NgModuleReason): void; function destroyRef(ref: PlatformRef, reason: NgModuleReason): void; function destroyRef(ref: PlatformRef | ApplicationRef | NgModuleRef, name?: string, reason?: string): void { if (ref) { + const refKind = ref instanceof PlatformRef ? 'PlatformRef' : ref instanceof NgModuleRef ? 'NgModuleRef' : ref instanceof ApplicationRef ? 'ApplicationRef' : '(unknown)'; + console.info(`[ns-hmr-diag][application] destroyRef kind=${refKind} name=${name ?? '(none)'} reason=${reason ?? '(none)'}`); if (ref instanceof PlatformRef) { preAngularDisposal$.next({ moduleType: 'platform', @@ -153,6 +203,7 @@ function destroyRef(ref: PlatformRef | ApplicationRef | NgModuleRef, name? }); } ref.destroy(); + console.info(`[ns-hmr-diag][application] destroyRef DONE kind=${refKind} name=${name ?? '(none)'}`); } } @@ -690,6 +741,11 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { disposePlatform('hotreload'); }; global['__reboot_ng_modules__'] = (shouldDisposePlatform: boolean = false) => { + // Diagnostic: bump the global HMR cycle counter so all subsequent + // log lines (class registry, dialog services) can be cross- + // referenced to a specific reboot. + const cycleNum = _hmrDiagBumpCycle(); + console.info(`[ns-hmr-diag][application] __reboot_ng_modules__ called cycle=${cycleNum} shouldDisposePlatform=${shouldDisposePlatform} bootstrapId=${bootstrapId} hasMainModuleRef=${!!mainModuleRef}`); const traceEnabled = NativeScriptDebug.isLogEnabled(); if (traceEnabled) { NativeScriptDebug.hmrLog( @@ -700,6 +756,7 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { global['__NS_CAPTURE_ANGULAR_HMR_ROUTE__']?.(); } catch {} disposeLastModules('hotreload'); + console.info(`[ns-hmr-diag][application] after disposeLastModules cycle=${cycleNum} bootstrapId=${bootstrapId}`); if (traceEnabled) { NativeScriptDebug.hmrLog(`after disposeLastModules bootstrapId=${bootstrapId}`); } @@ -709,7 +766,9 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { if (traceEnabled) { NativeScriptDebug.hmrLog('calling bootstrapRoot'); } + console.info(`[ns-hmr-diag][application] calling bootstrapRoot cycle=${cycleNum}`); bootstrapRoot('hotreload'); + console.info(`[ns-hmr-diag][application] bootstrapRoot returned cycle=${cycleNum} bootstrapId=${bootstrapId}`); if (traceEnabled) { NativeScriptDebug.hmrLog(`bootstrapRoot returned bootstrapId=${bootstrapId}`); } diff --git a/packages/angular/src/lib/cdk/dialog/dialog-config.ts b/packages/angular/src/lib/cdk/dialog/dialog-config.ts index ab47be0c..9234894f 100644 --- a/packages/angular/src/lib/cdk/dialog/dialog-config.ts +++ b/packages/angular/src/lib/cdk/dialog/dialog-config.ts @@ -58,5 +58,22 @@ export class NativeDialogConfig { nativeOptions?: NativeShowModalOptions = {}; + /** + * When true, this dialog will be re-opened automatically on Angular HMR + * reboots so the user does not lose context every time a related file + * changes. The new dialog reuses the same component class and `data` payload + * (provided via `data`); other config such as `nativeOptions` is preserved + * verbatim. + * + * The original `dialogRef.afterClosed()` subject is wired to the restored + * dialog so consumers `await openModal(...)` resolve normally when the user + * eventually closes the restored modal. + * + * Only opens via component class are restorable — `TemplateRef` openings + * carry references that don't survive an HMR reboot and are silently + * skipped. Has no effect outside of HMR. + */ + preserveOnHmr?: boolean = false; + // TODO(jelbourn): add configuration for lifecycle hooks, ARIA labelling. } diff --git a/packages/angular/src/lib/cdk/dialog/dialog-hmr-animation.spec.ts b/packages/angular/src/lib/cdk/dialog/dialog-hmr-animation.spec.ts new file mode 100644 index 00000000..efe735e4 --- /dev/null +++ b/packages/angular/src/lib/cdk/dialog/dialog-hmr-animation.spec.ts @@ -0,0 +1,123 @@ +import { NativeDialogConfig } from './dialog-config'; +import { buildNonAnimatedRestoreConfig, suppressNativeCloseAnimation } from './dialog-hmr-animation'; +import { HmrCandidateDialog } from './dialog-hmr'; + +class StubComponent {} + +function makeCandidate(opts: { + parentView?: { _modalAnimatedOptions?: boolean[] }; + preserveOnHmr?: boolean; +}): HmrCandidateDialog { + const config = new NativeDialogConfig(); + config.preserveOnHmr = opts.preserveOnHmr ?? true; + const ref: unknown = { + _nativeModalRef: opts.parentView ? { parentView: opts.parentView } : undefined, + }; + return { + ref: ref as HmrCandidateDialog['ref'], + componentClass: StubComponent as unknown as HmrCandidateDialog['componentClass'], + config, + }; +} + +describe('NativeDialog HMR animation helpers', () => { + describe('suppressNativeCloseAnimation', () => { + it('flips the top of the parent view animated stack to false so the next dismiss is un-animated', () => { + const stack: boolean[] = [true]; + const candidate = makeCandidate({ parentView: { _modalAnimatedOptions: stack } }); + + suppressNativeCloseAnimation(candidate); + + expect(stack).toEqual([false]); + }); + + it('only mutates the top entry so deeper presentations stay untouched', () => { + const stack: boolean[] = [true, true]; + const candidate = makeCandidate({ parentView: { _modalAnimatedOptions: stack } }); + + suppressNativeCloseAnimation(candidate); + + // The dismiss reads `slice(-1)[0]`; deeper entries belong to other + // open modals on the same parent view and must stay animated. + expect(stack).toEqual([true, false]); + }); + + it('skips the mutation when the candidate did not opt into preservation', () => { + const stack: boolean[] = [true]; + const candidate = makeCandidate({ + parentView: { _modalAnimatedOptions: stack }, + preserveOnHmr: false, + }); + + suppressNativeCloseAnimation(candidate); + + expect(stack).toEqual([true]); + }); + + it('is a no-op when the underlying native modal ref is missing', () => { + const candidate = makeCandidate({ parentView: undefined }); + + expect(() => suppressNativeCloseAnimation(candidate)).not.toThrow(); + }); + + it('is a no-op when the parent view exposes no animated stack', () => { + const candidate = makeCandidate({ parentView: {} }); + + expect(() => suppressNativeCloseAnimation(candidate)).not.toThrow(); + }); + + it('is a no-op when the animated stack is present but empty', () => { + const candidate = makeCandidate({ parentView: { _modalAnimatedOptions: [] } }); + + expect(() => suppressNativeCloseAnimation(candidate)).not.toThrow(); + }); + }); + + describe('buildNonAnimatedRestoreConfig', () => { + it('returns a NativeDialogConfig with nativeOptions.animated forced to false', () => { + const original = new NativeDialogConfig(); + original.nativeOptions = { animated: true, fullscreen: true } as never; + + const restore = buildNonAnimatedRestoreConfig(original); + + expect((restore.nativeOptions as Record)?.animated).toBe(false); + expect((restore.nativeOptions as Record)?.fullscreen).toBe(true); + }); + + it('does not mutate the original config so cached references stay intact', () => { + const original = new NativeDialogConfig(); + original.nativeOptions = { animated: true } as never; + + const restore = buildNonAnimatedRestoreConfig(original); + + expect(restore).not.toBe(original); + expect((original.nativeOptions as Record)?.animated).toBe(true); + }); + + it('synthesises a nativeOptions object when the original config has none', () => { + const original = new NativeDialogConfig(); + // Default config initialiser sets nativeOptions to {}, replicate + // the shape projects produce when they explicitly set it to + // undefined for some opens. + original.nativeOptions = undefined; + + const restore = buildNonAnimatedRestoreConfig(original); + + expect(restore.nativeOptions).toEqual({ animated: false }); + expect(original.nativeOptions).toBeUndefined(); + }); + + it('preserves the rest of the captured config (data, id, preserveOnHmr) so the reopened modal looks identical to the user', () => { + const original = new NativeDialogConfig(); + original.id = 'resource-modal'; + original.data = { resourceId: 42 }; + original.preserveOnHmr = true; + + const restore = buildNonAnimatedRestoreConfig(original); + + expect(restore.id).toBe('resource-modal'); + expect(restore.data).toEqual({ resourceId: 42 }); + expect(restore.preserveOnHmr).toBe(true); + }); + }); +}); diff --git a/packages/angular/src/lib/cdk/dialog/dialog-hmr-animation.ts b/packages/angular/src/lib/cdk/dialog/dialog-hmr-animation.ts new file mode 100644 index 00000000..53d0f5e0 --- /dev/null +++ b/packages/angular/src/lib/cdk/dialog/dialog-hmr-animation.ts @@ -0,0 +1,62 @@ +import { NativeDialogConfig } from './dialog-config'; +import { HmrCandidateDialog } from './dialog-hmr'; + +/** + * Best-effort animation helpers used by the dialog HMR layer to make + * the close + reopen round-trip feel like an in-place content refresh. + * + * They live in a tiny standalone module on purpose: + * + * - `dialog-services.ts` pulls in `@angular/core`, which Jest cannot + * load in our spec runner without an extra ESM transform. By + * keeping these helpers free of `@angular/core` we can unit-test + * them in isolation (`dialog-hmr-animation.spec.ts`) while + * `dialog-services.ts` re-exports them at the public API layer. + * - The helpers are inherently best-effort: a missing + * `_nativeModalRef`, a frozen `_modalAnimatedOptions` stack, or a + * future `NativeDialogConfig` shape change must never break HMR + * restore — we just fall back to the original animated behavior. + */ + +/** + * Mutate the top of `parentView._modalAnimatedOptions` to `false` for + * the given candidate so the imminent native close runs un-animated. + * + * iOS reads `_modalAnimatedOptions.slice(-1)[0]` when dismissing a + * modal (see core `view-common.ts` / `view/index.ios.ts`). The + * Angular dialog service only pushes one entry per open call, so the + * top entry is the exact flag that controls the dismiss we're about + * to trigger as part of the HMR root-view replacement. + */ +export function suppressNativeCloseAnimation(candidate: HmrCandidateDialog): void { + if (!candidate.config?.preserveOnHmr) { + return; + } + try { + const modalRef = (candidate.ref as unknown as { _nativeModalRef?: { parentView?: unknown } })?._nativeModalRef; + const parentView = modalRef?.parentView as { _modalAnimatedOptions?: boolean[] } | undefined; + const stack = parentView?._modalAnimatedOptions; + if (Array.isArray(stack) && stack.length > 0) { + stack[stack.length - 1] = false; + } + } catch { + // Swallow: a missing `_nativeModalRef` / `_modalAnimatedOptions` + // is acceptable — we just lose the no-animation optimisation. + } +} + +/** + * Build a `NativeDialogConfig` clone of `original` whose + * `nativeOptions.animated` is forced to `false`. Used when re-opening + * a captured modal so the open animation matches the suppressed + * close — together they make the HMR round-trip feel like a content + * refresh instead of a close/reopen. + */ +export function buildNonAnimatedRestoreConfig(original: NativeDialogConfig): NativeDialogConfig { + // Clone via `Object.assign` so consumers holding the original + // config (e.g. caching it for re-open) don't see mutations from + // the HMR pathway. + const cloned = Object.assign(new NativeDialogConfig(), original) as NativeDialogConfig; + cloned.nativeOptions = { ...(original?.nativeOptions || {}), animated: false }; + return cloned; +} diff --git a/packages/angular/src/lib/cdk/dialog/dialog-hmr.spec.ts b/packages/angular/src/lib/cdk/dialog/dialog-hmr.spec.ts new file mode 100644 index 00000000..6fd6ad54 --- /dev/null +++ b/packages/angular/src/lib/cdk/dialog/dialog-hmr.spec.ts @@ -0,0 +1,144 @@ +import { Subject } from 'rxjs'; +import { + abortCapturedDialog, + captureDialogsForHmr, + clearPendingHmrDialogs, + consumePendingHmrDialogs, + HmrCandidateDialog, + peekPendingHmrDialogs, + selectPreservableDialogs, +} from './dialog-hmr'; +import { NativeDialogConfig } from './dialog-config'; +import { NativeDialogRef } from './dialog-ref'; + +class StubComponent {} +class OtherStubComponent {} + +function makeRef(afterClosed: Subject): NativeDialogRef { + return { _afterClosed: afterClosed } as unknown as NativeDialogRef; +} + +function makeCandidate(opts: { component?: typeof StubComponent | typeof OtherStubComponent; preserveOnHmr?: boolean; subject?: Subject } = {}): HmrCandidateDialog { + const config = new NativeDialogConfig(); + config.preserveOnHmr = opts.preserveOnHmr; + const subject = opts.subject ?? new Subject(); + return { + ref: makeRef(subject), + componentClass: opts.component as any, + config, + }; +} + +describe('dialog-hmr', () => { + afterEach(() => { + clearPendingHmrDialogs(); + }); + + describe('selectPreservableDialogs', () => { + it('keeps only dialogs marked preserveOnHmr that have a real component class', () => { + const a = makeCandidate({ component: StubComponent, preserveOnHmr: true }); + const b = makeCandidate({ component: StubComponent, preserveOnHmr: false }); + const c = makeCandidate({ component: undefined, preserveOnHmr: true }); + + expect(selectPreservableDialogs([a, b, c])).toEqual([a]); + }); + }); + + describe('captureDialogsForHmr', () => { + it('stashes preservable dialogs onto globalThis so the next bootstrap can pick them up', () => { + const subject = new Subject(); + const candidate = makeCandidate({ component: StubComponent, preserveOnHmr: true, subject }); + + const captured = captureDialogsForHmr([candidate]); + + expect(captured).toHaveLength(1); + expect(captured[0].componentClass).toBe(StubComponent); + expect(peekPendingHmrDialogs()).toHaveLength(1); + }); + + it('captures the source class name so post-reboot restore can look up the fresh class by name', () => { + const candidate = makeCandidate({ component: StubComponent, preserveOnHmr: true }); + + const captured = captureDialogsForHmr([candidate]); + + expect(captured).toHaveLength(1); + expect(captured[0].componentName).toBe('StubComponent'); + }); + + it('uses the most-recently-defined class name even when the captured class is renamed in source', () => { + const candidateA = makeCandidate({ component: StubComponent, preserveOnHmr: true }); + const candidateB = makeCandidate({ component: OtherStubComponent, preserveOnHmr: true }); + + const captured = captureDialogsForHmr([candidateA, candidateB]); + + expect(captured.map((c) => c.componentName)).toEqual(['StubComponent', 'OtherStubComponent']); + }); + + it('clears any prior stash when nothing is preservable so a stale capture cannot leak forward', () => { + const stale = makeCandidate({ component: StubComponent, preserveOnHmr: true }); + captureDialogsForHmr([stale]); + expect(peekPendingHmrDialogs()).toHaveLength(1); + + const preservedNothing = captureDialogsForHmr([ + makeCandidate({ component: StubComponent, preserveOnHmr: false }), + ]); + + expect(preservedNothing).toEqual([]); + expect(peekPendingHmrDialogs()).toEqual([]); + }); + + it('grafts the captured afterClosed subject so the original consumer resolves on restoration', () => { + const subject = new Subject(); + const observed: unknown[] = []; + const completed: boolean[] = []; + subject.subscribe({ + next: (value) => observed.push(value), + complete: () => completed.push(true), + }); + + const captured = captureDialogsForHmr([makeCandidate({ component: StubComponent, preserveOnHmr: true, subject })]); + captured[0].graftAfterClosed('closed-value'); + + expect(observed).toEqual(['closed-value']); + expect(completed).toEqual([true]); + }); + + it('graft is a no-op when the captured subject already completed', () => { + const subject = new Subject(); + subject.complete(); + + const captured = captureDialogsForHmr([makeCandidate({ component: StubComponent, preserveOnHmr: true, subject })]); + + expect(() => captured[0].graftAfterClosed('ignored')).not.toThrow(); + }); + }); + + describe('consumePendingHmrDialogs', () => { + it('drains the stash so consecutive consumers do not see duplicates', () => { + captureDialogsForHmr([ + makeCandidate({ component: StubComponent, preserveOnHmr: true }), + makeCandidate({ component: OtherStubComponent, preserveOnHmr: true }), + ]); + + expect(consumePendingHmrDialogs()).toHaveLength(2); + expect(consumePendingHmrDialogs()).toHaveLength(0); + }); + + it('returns an empty list when nothing has been stashed', () => { + expect(consumePendingHmrDialogs()).toEqual([]); + }); + }); + + describe('abortCapturedDialog', () => { + it('completes the original subject so awaiting consumers do not dangle', () => { + const subject = new Subject(); + const completed: boolean[] = []; + subject.subscribe({ complete: () => completed.push(true) }); + + const captured = captureDialogsForHmr([makeCandidate({ component: StubComponent, preserveOnHmr: true, subject })]); + abortCapturedDialog(captured[0]); + + expect(completed).toEqual([true]); + }); + }); +}); diff --git a/packages/angular/src/lib/cdk/dialog/dialog-hmr.ts b/packages/angular/src/lib/cdk/dialog/dialog-hmr.ts new file mode 100644 index 00000000..170441b2 --- /dev/null +++ b/packages/angular/src/lib/cdk/dialog/dialog-hmr.ts @@ -0,0 +1,182 @@ +import { Subject } from 'rxjs'; +import { ComponentType } from '../../utils/general'; +import { NativeDialogConfig } from './dialog-config'; +import { NativeDialogRef } from './dialog-ref'; + +/** + * One captured dialog opening, kept around long enough to re-open with the + * fresh component class after Angular reboots. We deliberately keep this as + * `unknown`-shaped data (no rxjs `Subject` typing on the public stash) so the + * boundary between old and new module realms stays narrow. + */ +export interface CapturedHmrDialog { + /** + * The component class as it was at capture time. After an HMR reboot + * this reference is **stale** — the new module realm will export a + * different class object even though the source class definition is + * identical. We retain it as a fallback for production-like builds + * (where no class registry is installed) and for the rare case where + * a captured component was not loaded through the patched + * `ɵɵdefineComponent` path. + */ + componentClass: ComponentType; + /** + * The captured class's source name (e.g. `ResourceModalComponent`). + * The dialog restore step uses this name to look up the live class + * from `hmr-class-registry`, falling back to `componentClass` if the + * registry has no match. + */ + componentName: string; + config: NativeDialogConfig; + /** + * The original `_afterClosed` subject from the captured dialog ref. We pipe + * the restored dialog's `afterClosed()` into this subject so consumers that + * are awaiting the original `dialogRef.afterClosed()` resolve naturally. + * + * Stored as `unknown` because the rxjs class identity may be different + * between captured-vs-restored realms; the dialog-services consumer + * narrows it as needed. + */ + graftAfterClosed: (value: unknown) => void; +} + +const STASH_KEY = '__NS_ANGULAR_HMR_PENDING_MODALS__'; + +function getStashSlot(): { value: CapturedHmrDialog[] | undefined } { + return globalThis as unknown as { value: CapturedHmrDialog[] | undefined }; +} + +/** + * Pure helper: filter the open dialog list down to entries that opted in via + * `preserveOnHmr` and that we actually know how to restore (component-class + * openings, not template openings). + */ +export function selectPreservableDialogs( + openDialogs: ReadonlyArray, +): HmrCandidateDialog[] { + return openDialogs.filter((dialog) => isPreservable(dialog)); +} + +export interface HmrCandidateDialog { + /** The dialog ref we'd graft `afterClosed` onto. */ + ref: NativeDialogRef; + /** + * The component class used when the dialog was opened. May be `undefined` + * for `TemplateRef`-based openings — such dialogs are not preservable. + */ + componentClass?: ComponentType; + /** The original config so we can re-open with identical options. */ + config: NativeDialogConfig; +} + +function isPreservable(dialog: HmrCandidateDialog): boolean { + if (!dialog.config?.preserveOnHmr) { + return false; + } + return typeof dialog.componentClass === 'function'; +} + +/** + * Capture the open dialogs that opted into HMR preservation. Returns the + * captured entries so callers can correlate counts in their logs. + */ +export function captureDialogsForHmr(openDialogs: ReadonlyArray): CapturedHmrDialog[] { + const preservable = selectPreservableDialogs(openDialogs); + if (preservable.length === 0) { + clearPendingHmrDialogs(); + return []; + } + + const captures: CapturedHmrDialog[] = preservable.map(({ ref, componentClass, config }) => { + const subject = readAfterClosedSubject(ref); + // Capture the source name now — the class reference itself becomes + // stale after the reboot, but the name is stable across realms and + // is what the post-reboot registry is keyed on. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const componentName = (componentClass! as unknown as { name?: string })?.name ?? ''; + return { + // Asserted non-null in `isPreservable`. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + componentClass: componentClass!, + componentName, + config, + graftAfterClosed: (value) => { + if (!subject) { + return; + } + try { + if (!subject.closed) { + subject.next(value as never); + subject.complete(); + } + } catch { + // Swallow: the subject may have completed during dispose; nothing for us to do. + } + }, + }; + }); + + (globalThis as unknown as Record)[STASH_KEY] = captures; + return captures; +} + +/** + * Drain the pending captures. The caller (the new `NativeDialog`) is expected + * to re-open each entry and graft `afterClosed` back into the original + * subject. + */ +export function consumePendingHmrDialogs(): CapturedHmrDialog[] { + const slot = (globalThis as unknown as Record)[STASH_KEY]; + if (!Array.isArray(slot)) { + return []; + } + delete (globalThis as unknown as Record)[STASH_KEY]; + return slot.filter((entry): entry is CapturedHmrDialog => !!entry && typeof (entry as CapturedHmrDialog).componentClass === 'function'); +} + +/** + * Remove the pending captures without restoring. Useful when a reboot happens + * for reasons other than module replacement (e.g. platform dispose) and we + * don't want stale modal state to leak into the next bootstrap. + */ +export function clearPendingHmrDialogs(): void { + delete (globalThis as unknown as Record)[STASH_KEY]; +} + +/** + * Test/debug helper: read the current stash without consuming it. + */ +export function peekPendingHmrDialogs(): CapturedHmrDialog[] { + const slot = (globalThis as unknown as Record)[STASH_KEY]; + return Array.isArray(slot) ? (slot as CapturedHmrDialog[]).slice() : []; +} + +/** + * Reach into the dialog ref's private `_afterClosed` subject. We touch the + * private field intentionally — the ref class lives inside this package and + * we want HMR restore to be a feature of the dialog system rather than a + * reason to widen its public surface for everyone. + */ +function readAfterClosedSubject(ref: NativeDialogRef): Subject | undefined { + const candidate = (ref as unknown as { _afterClosed?: unknown })._afterClosed; + if (!candidate || typeof candidate !== 'object') { + return undefined; + } + if (typeof (candidate as Subject).next !== 'function') { + return undefined; + } + return candidate as Subject; +} + +/** + * Used by the resume-side: if the stash references something that can no + * longer be opened (e.g. the component class is dead post-reload), we still + * need to release its consumers so awaited promises don't dangle forever. + */ +export function abortCapturedDialog(captured: CapturedHmrDialog): void { + try { + captured.graftAfterClosed(undefined); + } catch { + // Best-effort. + } +} diff --git a/packages/angular/src/lib/cdk/dialog/dialog-module.ts b/packages/angular/src/lib/cdk/dialog/dialog-module.ts index 19e411e2..4d5aacee 100644 --- a/packages/angular/src/lib/cdk/dialog/dialog-module.ts +++ b/packages/angular/src/lib/cdk/dialog/dialog-module.ts @@ -1,10 +1,34 @@ import { NgModule } from '@angular/core'; import { NativeDialogCloseDirective } from './dialog-content-directives'; -import { NativeDialog } from './dialog-services'; +/** + * Convenience module that re-exports the `NativeDialogCloseDirective` for + * template-driven `[nativeDialogClose]` usage. + * + * **Important**: `NativeDialog` itself is **not** listed in this module's + * `providers` array. The service is `@Injectable({ providedIn: 'root' })`, + * which already registers a single root-level instance and is fully + * tree-shakeable. Listing it here as well caused Angular to treat the + * module-level provider and the `providedIn: 'root'` factory as + * *separate* registrations once the module was pulled into a standalone + * app via `importProvidersFrom(NativeDialogModule, ...)`. The duplicate + * registration triggered: + * + * - Two `NativeDialog` instances in the same root environment injector, + * each subscribing to `postAngularBootstrap$`, which produced + * duplicate restore attempts during HMR. + * - `NG0200: Circular dependency detected for NativeDialog` while a + * captured modal was being re-opened during HMR restore, because the + * second resolution started while the first was still in progress. + * + * Removing the redundant entry collapses both providers back into a + * single root-level instance, which is what `providedIn: 'root'` + * documents. App authors who explicitly wire `NativeDialog` themselves + * (e.g. in a feature module's `providers`) keep working unchanged + * because they're targeting the same class symbol. + */ @NgModule({ imports: [NativeDialogCloseDirective], exports: [NativeDialogCloseDirective], - providers: [NativeDialog], }) export class NativeDialogModule {} diff --git a/packages/angular/src/lib/cdk/dialog/dialog-services.ts b/packages/angular/src/lib/cdk/dialog/dialog-services.ts index 13417ccf..0c1c1962 100644 --- a/packages/angular/src/lib/cdk/dialog/dialog-services.ts +++ b/packages/angular/src/lib/cdk/dialog/dialog-services.ts @@ -16,15 +16,85 @@ import { TemplateRef, Type, } from '@angular/core'; -import { defer, Observable, Subject } from 'rxjs'; +import { Application, View } from '@nativescript/core'; +import { defer, Observable, Subject, Subscription } from 'rxjs'; import { startWith } from 'rxjs/operators'; +import { postAngularBootstrap$, preAngularDisposal$ } from '../../application'; +import { isAngularHmrEnabled } from '../../hmr-environment'; +import { getFreshComponentClass } from '../../hmr-class-registry'; +import { registerHmrEagerInstantiator } from '../../hmr-eager-services'; import { NSLocationStrategy } from '../../legacy/router/ns-location-strategy'; +import { NativeScriptDebug } from '../../trace'; import { ComponentType } from '../../utils/general'; import { ComponentPortal, TemplatePortal } from '../portal/common'; import { NativeDialogConfig } from './dialog-config'; +import { + abortCapturedDialog, + captureDialogsForHmr, + CapturedHmrDialog, + clearPendingHmrDialogs, + consumePendingHmrDialogs, + HmrCandidateDialog, + peekPendingHmrDialogs, +} from './dialog-hmr'; +import { buildNonAnimatedRestoreConfig, suppressNativeCloseAnimation } from './dialog-hmr-animation'; import { NativeDialogRef } from './dialog-ref'; import { NativeModalRef } from './native-modal-ref'; +/** + * Always-visible HMR diagnostic prefix. We use the same `[ns-hmr][angular]` + * tag the Vite Angular client uses for refresh/reboot lines so devs see + * dialog HMR events on the same console channel without flipping + * `Trace.isEnabled()` (which is off by default and gates + * `NativeScriptDebug.hmrLog`). The helper short-circuits in production + * because every caller is already gated on `isAngularHmrEnabled()`. + */ +function hmrDialogLog(message: string): void { + if (!isAngularHmrEnabled()) { + return; + } + console.info(`[ns-hmr][angular][dialog] ${message}`); +} + +/** + * Diagnostic helper. Distinct from `hmrDialogLog` so we can grep + * separately for "low-level wiring" facts (module-realm count, + * NativeDialog instance count, registry hits/misses) vs. high-level + * lifecycle messages. + */ +function hmrDialogDiag(message: string): void { + if (!isAngularHmrEnabled()) { + return; + } + console.info(`[ns-hmr-diag][dialog] ${message}`); +} + +/** + * Module-evaluation marker. Increments on every fresh evaluation of + * `dialog-services.ts`. If we see this number rise on every HMR cycle, + * the file is being re-evaluated (good). If it stays flat, the module + * is being served from cache (bad — class identities won't change). + */ +const DIALOG_MODULE_DIAG_KEY = '__NS_HMR_DIAG_DIALOG_MODULE__'; +interface DialogModuleDiag { + evals: number; + instances: number; + lastEvalAt: number; +} +function getDialogModuleDiag(): DialogModuleDiag { + const slot = globalThis as unknown as { [DIALOG_MODULE_DIAG_KEY]?: DialogModuleDiag }; + if (!slot[DIALOG_MODULE_DIAG_KEY]) { + slot[DIALOG_MODULE_DIAG_KEY] = { evals: 0, instances: 0, lastEvalAt: 0 }; + } + return slot[DIALOG_MODULE_DIAG_KEY]!; +} +{ + const md = getDialogModuleDiag(); + md.evals += 1; + md.lastEvalAt = Date.now(); + hmrDialogDiag(`module-eval count=${md.evals} (file=dialog-services.ts) timestamp=${md.lastEvalAt}`); +} + /** Injection token that can be used to access the data that was passed in to a dialog. */ export const NATIVE_DIALOG_DATA = new InjectionToken('NativeDialogData'); @@ -39,9 +109,24 @@ export const NATIVE_DIALOG_DEFAULT_OPTIONS = new InjectionToken[] = []; private readonly _afterAllClosedAtThisLevel = new Subject(); private readonly _afterOpenedAtThisLevel = new Subject>(); + /** + * Maps each open dialog ref back to the `(componentClass, config)` pair it + * was opened with so the HMR snapshot can replay the call later. Dialogs + * opened with a `TemplateRef` are tracked with `componentClass: undefined` + * — the HMR layer skips them automatically. + */ + private readonly _openDialogMetadata = new WeakMap, { componentClass?: ComponentType; config: NativeDialogConfig }>(); + private _hmrSubscriptions: Subscription[] = []; // TODO (jelbourn): tighten the typing right-hand side of this expression. /** * Stream that emits when all open dialog have finished closing. @@ -76,6 +161,23 @@ export class NativeDialog implements OnDestroy { private _nativeModalType = NativeModalRef; private _dialogDataToken = NATIVE_DIALOG_DATA; private locationStrategy = inject(NSLocationStrategy); + // Bumps a global counter so we can detect duplicate or leaked + // `NativeDialog` instances across HMR cycles. Field initialiser + // ordering: this MUST run before `_initHmrLifecycle()` below so the + // log line in that helper can include the assigned id. + private _diagInstanceIdAssign = ((): null => { + const md = getDialogModuleDiag(); + md.instances += 1; + (this as unknown as { _diagInstanceId: number })._diagInstanceId = md.instances; + hmrDialogDiag( + `NativeDialog ctor instanceId=${md.instances} hasParentDialog=${!!this._parentDialog} moduleEvalCount=${md.evals}`, + ); + return null; + })(); + // Initialise after every dependency above so the subscriptions can call + // back into `this.open(...)` and `this.openDialogs` safely. The result is + // unused — we just want a side-effect at construction time. + private _hmrInitMarker = this._initHmrLifecycle(); /** * Opens a modal dialog containing the given component. * @param component Type of the component to load into the dialog. @@ -109,6 +211,10 @@ export class NativeDialog implements OnDestroy { const dialogRef = this._attachDialogContent(componentOrTemplateRef, config); this.openDialogs.push(dialogRef); + this._openDialogMetadata.set(dialogRef, { + componentClass: componentOrTemplateRef instanceof TemplateRef ? undefined : (componentOrTemplateRef as ComponentType), + config, + }); dialogRef.afterClosed().subscribe(() => this._removeOpenDialog(dialogRef)); this.afterOpened.next(dialogRef); @@ -134,11 +240,405 @@ export class NativeDialog implements OnDestroy { } ngOnDestroy() { + hmrDialogDiag( + `NativeDialog ngOnDestroy instanceId=${this._diagInstanceId} openCount=${this._openDialogsAtThisLevel.length} subCount=${this._hmrSubscriptions.length}`, + ); // Only close the dialogs at this level on destroy // since the parent service may still be active. this._closeDialogs(this._openDialogsAtThisLevel); this._afterAllClosedAtThisLevel.complete(); this._afterOpenedAtThisLevel.complete(); + for (const sub of this._hmrSubscriptions) { + try { + sub.unsubscribe(); + } catch { + // Best-effort: tearing down the dialog service shouldn't prevent the + // rest of the module disposal from completing. + } + } + this._hmrSubscriptions = []; + } + + /** + * Tracks whether a restore has already been scheduled for this + * `NativeDialog` instance's lifetime. We only need to restore once + * per HMR cycle — the rxjs `ReplaySubject(1)` for + * `postAngularBootstrap$` delivers both the *previous* cycle's + * cached event (replay on subscribe) **and** the *current* cycle's + * fresh event, and the constructor stash peek can independently + * notice pending work. Without this guard each of those triggers + * would queue its own `setTimeout` and the logs would show two or + * three "scheduling restore" lines per save. + * + * The guard is per-instance and the stash itself is the source of + * truth: `_restorePendingDialogs` calls `consumePendingHmrDialogs()` + * which atomically clears the stash, so even if the guard somehow + * fired twice, only the first call would do real work. + */ + private _restoreScheduledForThisInstance = false; + + /** + * Wires up HMR capture/restore. Only the root-level dialog manages the + * stash so a stack of `NativeDialog` instances inside a child injector + * doesn't fight for it. + * + * Production short-circuit: `isAngularHmrEnabled()` returns `false` in + * release builds and when no NS Vite / webpack HMR runtime is present, + * so the long-lived subscriptions below never attach in shipping apps. + * + * `postAngularBootstrap$` is a `ReplaySubject(1)` (see `application.ts`) + * which means a `NativeDialog` instantiated *after* the bootstrap event + * has already fired (typical when the user app injects `NativeDialog` + * lazily via a service like `view.service.ts`) still receives the + * buffered event and runs the restore path. + */ + private _initHmrLifecycle(): null { + if (this._parentDialog) { + hmrDialogDiag(`_initHmrLifecycle skipped (has parent dialog) instanceId=${this._diagInstanceId}`); + return null; + } + + if (!isAngularHmrEnabled()) { + return null; + } + + hmrDialogDiag( + `_initHmrLifecycle wiring up subscriptions instanceId=${this._diagInstanceId} moduleEvalCount=${getDialogModuleDiag().evals}`, + ); + + const dispose = preAngularDisposal$.subscribe((event) => { + if (event.moduleType !== 'main' || event.reason !== 'hotreload') { + return; + } + hmrDialogDiag(`preAngularDisposal$ fired (reason=${event.reason}) instanceId=${this._diagInstanceId}`); + this._captureOpenDialogsForHmr(); + }); + + const bootstrap = postAngularBootstrap$.subscribe((event) => { + if (event.moduleType !== 'main' || event.reason !== 'hotreload') { + return; + } + hmrDialogDiag(`postAngularBootstrap$ fired (reason=${event.reason}) instanceId=${this._diagInstanceId}`); + this._maybeScheduleRestore(`postAngularBootstrap$ (reason=${event.reason})`); + }); + + this._hmrSubscriptions.push(dispose, bootstrap); + + // Belt-and-suspenders: even though `postAngularBootstrap$` replays + // the last event for late subscribers, also peek the global stash + // here. This catches the case where `NativeDialog` is instantiated + // lazily — *after* `emitModuleBootstrapEvent` has fired and the + // ReplaySubject's buffered event no longer matches the current + // cycle. The `_maybeScheduleRestore` guard makes this a no-op when + // the bootstrap subscriber already queued work. + const pendingNow = peekPendingHmrDialogs(); + hmrDialogDiag(`_initHmrLifecycle stash peek pending=${pendingNow.length} instanceId=${this._diagInstanceId}`); + if (pendingNow.length > 0) { + this._maybeScheduleRestore(`stash peek on ctor: ${pendingNow.length} pending dialog(s)`); + } + return null; + } + + /** + * Schedule a restore exactly once per `NativeDialog` instance. + * + * The work is deferred to the next macrotask for two reasons: + * + * 1. `postAngularBootstrap$.next(...)` is fired from inside + * `emitModuleBootstrapEvent`, which itself runs inside the + * `bootstrapApplication` callback — so the call stack still + * contains Angular's ApplicationRef bootstrap pipeline. Doing + * `this.open(...)` synchronously re-entered Angular DI while a + * `providedIn: 'root'` factory could still be on the resolution + * stack, which surfaced as `NG0200: Circular dependency detected + * for NativeDialog`. Yielding to a macrotask lets the bootstrap + * stack fully unwind first. + * 2. The eventual `parent.showModal(...)` cannot present onto a + * view controller whose view is not yet in the iOS window + * hierarchy (see `_scheduleRestoreOpenWhenReady` for the second + * wait stage); a synchronous attempt would silently no-op + * because iOS rejects the present without throwing. + */ + private _maybeScheduleRestore(triggerDescription: string): void { + if (this._restoreScheduledForThisInstance) { + hmrDialogDiag( + `_maybeScheduleRestore SKIP duplicate trigger=${triggerDescription} instanceId=${this._diagInstanceId}`, + ); + return; + } + this._restoreScheduledForThisInstance = true; + hmrDialogLog(`scheduling restore (trigger=${triggerDescription}) instanceId=${this._diagInstanceId}`); + setTimeout(() => { + void this._restorePendingDialogs(); + }, 0); + } + + private _captureOpenDialogsForHmr(): void { + const candidates: HmrCandidateDialog[] = this._openDialogsAtThisLevel.map((ref) => { + const meta = this._openDialogMetadata.get(ref); + return { + ref, + componentClass: meta?.componentClass, + config: meta?.config ?? new NativeDialogConfig(), + }; + }); + + hmrDialogDiag( + `_captureOpenDialogsForHmr instanceId=${this._diagInstanceId} candidates=${candidates.length} (${candidates.map((c) => `${(c.componentClass as { name?: string } | undefined)?.name ?? '(template)'}|preserveOnHmr=${!!c.config?.preserveOnHmr}`).join(', ')})`, + ); + + const captured = captureDialogsForHmr(candidates); + + if (captured.length > 0) { + // Suppress the close animation that's about to fire as part of + // `_closeAllModalViewsInternal()` during root-view replacement. + // Without this the user sees a slide-down + slide-up flicker + // wrapping every HMR reboot. + for (const candidate of candidates) { + suppressNativeCloseAnimation(candidate); + } + hmrDialogLog(`captured ${captured.length} dialog(s) for HMR restore [${captured.map((c) => c.componentName).join(', ')}]`); + } else if (this._openDialogsAtThisLevel.length > 0) { + hmrDialogLog(`skipped capture: ${this._openDialogsAtThisLevel.length} open dialog(s) but none preservable`); + } + + if (captured.length > 0 && NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`captured ${captured.length} dialog(s) for HMR restore`); + } + } + + /** + * Per-cycle guard to keep `_restorePendingDialogs` idempotent if more + * than one subscriber fires for the same bootstrap event. The + * regression we have seen (`postAngularBootstrap$ → restore` logged + * twice in the same hot reload) was caused by the `NativeDialogModule` + * also listing `NativeDialog` in its `providers` array. That has been + * removed, but we keep this flag as a defensive net so a future stray + * subscription does not consume the stash twice. The flag is reset + * after the consume so subsequent HMR cycles can run their own + * restore. + */ + private _restoreInFlight = false; + + private async _restorePendingDialogs(): Promise { + if (this._restoreInFlight) { + hmrDialogLog('skipping restore: already in flight'); + return; + } + const pending = consumePendingHmrDialogs(); + if (pending.length === 0) { + return; + } + this._restoreInFlight = true; + + hmrDialogLog(`restoring ${pending.length} dialog(s) after reboot [${pending.map((c) => c.componentName).join(', ')}]`); + + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`restoring ${pending.length} dialog(s) after HMR reboot`); + } + + try { + for (const captured of pending) { + this._restoreSingleDialog(captured); + } + } finally { + this._restoreInFlight = false; + } + } + + /** + * Resolve the freshest known class object for the captured component + * name and re-open the dialog through the normal `open` path, but + * only once the new root view is actually attached to the iOS window + * hierarchy. + * + * Why a class lookup at all: an HMR reboot calls + * `ɵresetCompiledComponents()` which clears each component's `ɵcmp` + * field but **leaves the class identity unchanged**. The patched + * `ɵɵdefineComponent` then re-registers the same class object under + * the same source name when Angular re-renders the component. A + * fresh-class lookup therefore returns either the same object the + * stash captured (most common) or, on the rare occasion that the + * source file's `@Component` decorator was re-evaluated into a brand + * new class object (e.g. the user added `@Component(...)` to a new + * exported symbol), the live one. Either way, a single check is + * enough — the previous retry schedule was a no-op in 100 % of + * observed cycles because the captured class IS the live class. + */ + private _restoreSingleDialog(captured: CapturedHmrDialog): void { + const live = getFreshComponentClass>(captured.componentName); + const componentClass = live ?? captured.componentClass; + const usingFresh = !!live && live !== captured.componentClass; + // Detailed diagnostic to disambiguate the three reasons + // `usingFreshClass=false` could log: + // 1. liveDefined=false: the patched ɵɵdefineComponent never + // registered a class for this name in the new realm. Most + // likely cause: the component module was not re-evaluated + // after the eviction (file not in `evictPaths`, or runtime + // did not actually evict it). + // 2. liveDefined=true but live === captured: the file WAS + // re-evaluated, but the registry still hands back the same + // class object. This shouldn't happen post-reboot for a + // truly fresh realm; if it does, the captured snapshot was + // taken from the same realm that's now serving the live + // class — i.e. the disposal path is not actually disposing + // the old realm before the new one boots. + // 3. liveDefined=true and live !== captured: usingFresh path, + // the registry IS doing its job. This is the success case. + const liveDefined = !!live; + const sameAsCapture = liveDefined && live === captured.componentClass; + hmrDialogDiag( + `_restoreSingleDialog name=${captured.componentName} liveDefined=${liveDefined} sameAsCapture=${sameAsCapture} usingFresh=${usingFresh} capturedFnName=${(captured.componentClass as unknown as { name?: string })?.name ?? '(none)'} liveFnName=${(live as unknown as { name?: string } | undefined)?.name ?? '(none)'}`, + ); + this._scheduleRestoreOpenWhenReady(captured, componentClass, usingFresh); + } + + /** + * Maximum time we'll wait for the new root view to attach to the + * iOS window before giving up and trying the open anyway. NS Vite's + * worst observed reboot-to-rootview-loaded gap is ~250 ms; one + * second leaves headroom for slower devices without leaving the + * captured dialog stuck in the stash if something genuinely goes + * wrong. + */ + private static readonly _ROOT_VIEW_LOADED_TIMEOUT_MS = 1_000; + + /** + * Defer the actual `this.open(...)` call until the new root view's + * underlying iOS `UIViewController.view` is in the window hierarchy. + * + * iOS silently rejects `presentViewControllerAnimatedCompletion` if + * the parent controller's view is not yet attached to a `UIWindow` + * (see `view/index.ios.ts::_showNativeModalView`, which logs to + * `Trace` and returns without throwing). In dev that would surface + * as a vanished modal with no actionable error. + * + * NS marks a view as `isLoaded === true` from + * `UILayoutViewController.viewWillAppear`, which fires once iOS has + * decided to add the view to its window. Listening for the + * `loadedEvent` (one-shot) plus an extra macrotask gives UIKit a + * chance to finish window attachment before we present. + */ + private _scheduleRestoreOpenWhenReady( + captured: CapturedHmrDialog, + componentClass: ComponentType, + usingFresh: boolean, + ): void { + const rootView = Application.getRootView(); + + if (rootView && rootView.isLoaded) { + // Even when isLoaded is already true we yield once: a previous + // capture in the same hot reload cycle may have set isLoaded on + // the *outgoing* root view and a new root view is about to take + // over. A single setTimeout(0) gives `setWindowContent` a chance + // to finish swapping `win.rootViewController` before we try to + // present on top of it. + setTimeout(() => this._performRestoreOpen(captured, componentClass, usingFresh), 0); + return; + } + + if (!rootView) { + // No root view at all yet — extremely unusual at this point in + // the bootstrap, but protect against it by polling. We use the + // same timeout budget as the loaded path. + this._pollForRootView(captured, componentClass, usingFresh, Date.now()); + return; + } + + hmrDialogLog(`restore ${captured.componentName} waiting for root view loadedEvent`); + + let settled = false; + const onLoaded = () => { + if (settled) return; + settled = true; + try { + rootView.off(View.loadedEvent, onLoaded); + } catch { + // off may throw on stale view bindings; the `settled` flag + // already prevents a double-fire. + } + // Defer one tick after viewWillAppear so UIKit completes the + // actual window attachment (`view.window` is set during the + // view-controller transition that follows viewWillAppear). + setTimeout(() => this._performRestoreOpen(captured, componentClass, usingFresh), 0); + }; + + try { + rootView.once(View.loadedEvent, onLoaded); + } catch { + // If the event subscription fails (ancient core builds), fall + // back to a tiny delay — better to attempt the open and have + // iOS log a benign trace than to leak the dialog stash. + setTimeout(() => onLoaded(), 50); + } + + // Bound the wait so a never-loading root view can't permanently + // pin the captured dialog in the stash. + setTimeout(() => { + if (settled) return; + hmrDialogLog(`restore ${captured.componentName} root view never loaded within ${NativeDialog._ROOT_VIEW_LOADED_TIMEOUT_MS}ms; attempting open anyway`); + onLoaded(); + }, NativeDialog._ROOT_VIEW_LOADED_TIMEOUT_MS); + } + + private _pollForRootView( + captured: CapturedHmrDialog, + componentClass: ComponentType, + usingFresh: boolean, + startedAt: number, + ): void { + const rootView = Application.getRootView(); + if (rootView) { + this._scheduleRestoreOpenWhenReady(captured, componentClass, usingFresh); + return; + } + if (Date.now() - startedAt > NativeDialog._ROOT_VIEW_LOADED_TIMEOUT_MS) { + hmrDialogLog(`restore ${captured.componentName} aborted: no root view after ${NativeDialog._ROOT_VIEW_LOADED_TIMEOUT_MS}ms`); + abortCapturedDialog(captured); + return; + } + setTimeout(() => this._pollForRootView(captured, componentClass, usingFresh, startedAt), 16); + } + + private _performRestoreOpen( + captured: CapturedHmrDialog, + componentClass: ComponentType, + usingFresh: boolean, + ): void { + hmrDialogLog(`restore ${captured.componentName} usingFreshClass=${usingFresh}`); + if (NativeScriptDebug.isLogEnabled() && usingFresh) { + NativeScriptDebug.hmrLog(`HMR modal restore using fresh class for ${captured.componentName}`); + } + + // Force the restored modal to open without animation so the round- + // trip looks like an instant content refresh rather than a full + // close-and-reopen sequence. + const restoreConfig = buildNonAnimatedRestoreConfig(captured.config); + + try { + const newRef = this.open(componentClass, restoreConfig); + hmrDialogLog(`restore ${captured.componentName} → opened newRef.id=${newRef?.id ?? 'n/a'}`); + newRef.afterClosed().subscribe({ + next: (value) => captured.graftAfterClosed(value), + complete: () => captured.graftAfterClosed(undefined), + }); + } catch (err) { + abortCapturedDialog(captured); + const message = (err as Error)?.message ?? String(err); + hmrDialogLog(`restore ${captured.componentName} FAILED: ${message}`); + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLogError(`HMR modal restore failed: ${message}`); + } + } + } + + /** + * Test/debug helper: discard any captured modals without restoring them. + * Mostly useful when projects want to opt out of restoration without + * recompiling. Public callers should prefer `preserveOnHmr: false` instead. + */ + static _clearPendingHmrDialogs(): void { + clearPendingHmrDialogs(); } /** @@ -208,6 +708,7 @@ export class NativeDialog implements OnDestroy { if (index > -1) { this.openDialogs.splice(index, 1); + this._openDialogMetadata.delete(dialogRef); // If all the dialogs were closed, remove/restore the `aria-hidden` // to a the siblings and emit to the `afterAllClosed` stream. @@ -241,6 +742,41 @@ function _applyConfigDefaults(config?: NativeDialogConfig, defaultOptions?: Nati return { ...defaultOptions, ...config }; } +/** + * Register `NativeDialog` with the application's HMR eager-instantiate + * registry so the post-bootstrap pipeline forces an `injector.get()` on + * the service in the same JS task as the bootstrap event. Without this, + * `NativeDialog` is only constructed when something in user-app code + * injects it (typically a wrapper service that opens modals). When that + * injection happens lazily — on first user-driven modal open — captured + * dialogs from a prior HMR cycle wait until the user reopens *something* + * before the new realm even sees the stash. Eager instantiation makes + * the restore work happen as early as possible during a hot reload while + * staying gated to dev mode + HMR-active environments. + * + * Registered after the class declaration so the closure references the + * fully-defined class rather than tripping the temporal dead zone. + * + * Idempotent: the registry de-dupes function references, so multiple + * evaluations of this module across HMR cycles never accumulate stale + * registrations. + */ +if (isAngularHmrEnabled()) { + const added = registerHmrEagerInstantiator((injector: Injector) => { + try { + const inst = injector.get(NativeDialog, null); + hmrDialogDiag(`eager-instantiator fired NativeDialog=${inst ? `instance#${(inst as unknown as { _diagInstanceId?: number })._diagInstanceId}` : 'null'}`); + } catch (err) { + hmrDialogDiag(`eager-instantiator threw: ${(err as Error)?.message ?? err}`); + // Some user-app providers may not include `NativeDialog` (e.g. a + // module that doesn't depend on the dialog feature). The registry + // contract is "best effort": failing to find the token must be a + // silent no-op so unrelated apps aren't penalized. + } + }); + hmrDialogDiag(`registerHmrEagerInstantiator added=${added} (false means already present in registry)`); +} + export { /** * @deprecated Use `NativeDialog` instead. diff --git a/packages/angular/src/lib/hmr-class-registry.spec.ts b/packages/angular/src/lib/hmr-class-registry.spec.ts new file mode 100644 index 00000000..0187fdf7 --- /dev/null +++ b/packages/angular/src/lib/hmr-class-registry.spec.ts @@ -0,0 +1,240 @@ +import { + _hmrDiagBumpCycle, + _hmrDiagSnapshot, + _registerComponentForHmr, + clearAngularHmrClassRegistry, + getFreshComponentClass, + getRegisteredComponentUrl, + installAngularHmrComponentRegistrar, +} from './hmr-class-registry'; + +const NG_DEV_MODE_KEY = 'ngDevMode'; +const VITE_HMR_SIGNAL_KEY = '__NS_DEV_PLACEHOLDER_ROOT_EARLY__'; +const HOOK_KEY = '__NS_HMR_REGISTER_COMPONENT__'; + +function withDevMode(value: boolean | undefined, run: () => T): T { + const slot = globalThis as unknown as Record; + const previous = slot[NG_DEV_MODE_KEY]; + if (value === undefined) { + delete slot[NG_DEV_MODE_KEY]; + } else { + slot[NG_DEV_MODE_KEY] = value; + } + try { + return run(); + } finally { + if (previous === undefined) { + delete slot[NG_DEV_MODE_KEY]; + } else { + slot[NG_DEV_MODE_KEY] = previous; + } + } +} + +function withViteHmrSignal(active: boolean, run: () => T): T { + const slot = globalThis as unknown as Record; + const previous = slot[VITE_HMR_SIGNAL_KEY]; + if (active) { + slot[VITE_HMR_SIGNAL_KEY] = true; + } else { + delete slot[VITE_HMR_SIGNAL_KEY]; + } + try { + return run(); + } finally { + if (previous === undefined) { + delete slot[VITE_HMR_SIGNAL_KEY]; + } else { + slot[VITE_HMR_SIGNAL_KEY] = previous; + } + } +} + +describe('hmr-class-registry', () => { + afterEach(() => { + clearAngularHmrClassRegistry(); + }); + + describe('installAngularHmrComponentRegistrar', () => { + // The registrar installs unconditionally. Production safety comes + // from the Vite plugin (`apply: 'serve'`), not from a runtime gate + // on this hook. A previous version short-circuited on + // `isAngularHmrEnabled()` and that produced a real-world race + // where `application.ts` evaluated before NativeScript Vite set + // its HMR globals; the hook was never installed and the registry + // stayed empty. See the comment block on the function for the + // full rationale. + it('installs the global hook in production too (the hook is just never called from there)', () => { + withDevMode(false, () => { + installAngularHmrComponentRegistrar(); + const hook = (globalThis as Record)[HOOK_KEY]; + expect(typeof hook).toBe('function'); + }); + }); + + it('installs the global hook even when no HMR signal is active', () => { + withDevMode(true, () => { + withViteHmrSignal(false, () => { + installAngularHmrComponentRegistrar(); + const hook = (globalThis as Record)[HOOK_KEY]; + expect(typeof hook).toBe('function'); + }); + }); + }); + + it('installs the global __NS_HMR_REGISTER_COMPONENT__ hook when vite HMR is active', () => { + withDevMode(true, () => { + withViteHmrSignal(true, () => { + installAngularHmrComponentRegistrar(); + const hook = (globalThis as Record)[HOOK_KEY]; + expect(typeof hook).toBe('function'); + }); + }); + }); + + it('hook registers a class so getFreshComponentClass returns it', () => { + withDevMode(true, () => { + withViteHmrSignal(true, () => { + installAngularHmrComponentRegistrar(); + const hook = (globalThis as Record)[HOOK_KEY] as ( + name: string, + cls: unknown, + url?: string, + ) => void; + class FooComponent {} + hook('FooComponent', FooComponent, 'http://localhost:5173/ns/m/src/foo.component.ts'); + expect(getFreshComponentClass('FooComponent')).toBe(FooComponent); + expect(getRegisteredComponentUrl('FooComponent')).toBe('http://localhost:5173/ns/m/src/foo.component.ts'); + }); + }); + }); + + it('returns the latest registered class on repeated registration calls (HMR reboot scenario)', () => { + withDevMode(true, () => { + withViteHmrSignal(true, () => { + installAngularHmrComponentRegistrar(); + const hook = (globalThis as Record)[HOOK_KEY] as ( + name: string, + cls: unknown, + url?: string, + ) => void; + + class FooV1 {} + class FooV2 {} + Object.defineProperty(FooV1, 'name', { value: 'FooComponent' }); + Object.defineProperty(FooV2, 'name', { value: 'FooComponent' }); + + hook('FooComponent', FooV1, ''); + expect(getFreshComponentClass('FooComponent')).toBe(FooV1); + + hook('FooComponent', FooV2, ''); + expect(getFreshComponentClass('FooComponent')).toBe(FooV2); + }); + }); + }); + + it('is idempotent: installing twice does not replace the hook', () => { + withDevMode(true, () => { + withViteHmrSignal(true, () => { + installAngularHmrComponentRegistrar(); + const firstHook = (globalThis as Record)[HOOK_KEY]; + installAngularHmrComponentRegistrar(); + const secondHook = (globalThis as Record)[HOOK_KEY]; + expect(secondHook).toBe(firstHook); + }); + }); + }); + + it('survives a simulated HMR reboot — the same global registry stays populated', () => { + // This is the critical regression test. Before this fix, the + // registrar patched `ɵɵdefineComponent` on `@angular/core`, + // and the patch silently failed because the export binding is + // immutable in ESM. With the new self-registration approach, + // each component module pushes its fresh class through the + // global hook on every re-evaluation, so the registry stays + // current across as many reboots as needed. + withDevMode(true, () => { + withViteHmrSignal(true, () => { + installAngularHmrComponentRegistrar(); + const hook = (globalThis as Record)[HOOK_KEY] as ( + name: string, + cls: unknown, + url?: string, + ) => void; + + class CycleOne {} + class CycleTwo {} + Object.defineProperty(CycleOne, 'name', { value: 'ResourceModalComponent' }); + Object.defineProperty(CycleTwo, 'name', { value: 'ResourceModalComponent' }); + + _hmrDiagBumpCycle(); + hook('ResourceModalComponent', CycleOne, ''); + expect(getFreshComponentClass('ResourceModalComponent')).toBe(CycleOne); + + // Simulate an HMR reboot: a brand-new class object with the + // same source name comes through the hook. The registrar + // doesn't need to be re-installed; the hook is the same + // function, but the registry snapshot now points at the + // fresh class. + _hmrDiagBumpCycle(); + hook('ResourceModalComponent', CycleTwo, ''); + expect(getFreshComponentClass('ResourceModalComponent')).toBe(CycleTwo); + expect(_hmrDiagSnapshot().registerCalls).toBe(2); + }); + }); + }); + }); + + describe('_registerComponentForHmr', () => { + it('skips registration for falsy names', () => { + class Foo {} + _registerComponentForHmr('', Foo); + expect(getFreshComponentClass('')).toBeUndefined(); + }); + + it('skips registration for nullish class refs', () => { + _registerComponentForHmr('Foo', null); + _registerComponentForHmr('Foo', undefined); + expect(getFreshComponentClass('Foo')).toBeUndefined(); + }); + + it('records the URL alongside the class', () => { + class Foo {} + _registerComponentForHmr('Foo', Foo, 'http://localhost:5173/ns/m/foo.ts'); + expect(getRegisteredComponentUrl('Foo')).toBe('http://localhost:5173/ns/m/foo.ts'); + }); + }); + + describe('getFreshComponentClass', () => { + it('returns undefined when no class has been registered', () => { + expect(getFreshComponentClass('NeverRegistered')).toBeUndefined(); + }); + + it('returns undefined for an empty name', () => { + expect(getFreshComponentClass('')).toBeUndefined(); + }); + }); + + describe('getRegisteredComponentUrl', () => { + it('returns undefined when no class has been registered', () => { + expect(getRegisteredComponentUrl('NeverRegistered')).toBeUndefined(); + }); + + it('returns undefined when registration omitted the URL', () => { + withDevMode(true, () => { + withViteHmrSignal(true, () => { + installAngularHmrComponentRegistrar(); + const hook = (globalThis as Record)[HOOK_KEY] as ( + name: string, + cls: unknown, + url?: string, + ) => void; + class Foo {} + hook('Foo', Foo); + expect(getFreshComponentClass('Foo')).toBe(Foo); + expect(getRegisteredComponentUrl('Foo')).toBeUndefined(); + }); + }); + }); + }); +}); diff --git a/packages/angular/src/lib/hmr-class-registry.ts b/packages/angular/src/lib/hmr-class-registry.ts new file mode 100644 index 00000000..425cd1b1 --- /dev/null +++ b/packages/angular/src/lib/hmr-class-registry.ts @@ -0,0 +1,333 @@ +/** + * Fresh-class registry for HMR. + * + * After an HMR reboot, every previously imported component module is + * re-evaluated. Each `@Component()`-decorated class becomes a *new* class + * object — it shares the source name (e.g. `ResourceModalComponent`) but + * has a different identity from the class the host code captured before + * the reboot. + * + * Helpers like `NativeDialog._restoreSingleDialog` need to re-open a + * captured modal *with the new class so the visual update applies*. + * Holding onto the pre-reboot class reference reopens the modal with + * the old metadata, which manifests as "the change appears the next + * time I close and re-open the modal myself, but not when HMR auto- + * reopens it." + * + * The mechanism is: + * + * 1. The Vite plugin `ns-component-hmr-register` (in + * `@nativescript/vite/configuration/angular`) injects a + * registration call at the end of every user `.ts` file that + * defines an `@Component`-decorated class: + * + * if (typeof globalThis.__NS_HMR_REGISTER_COMPONENT__ === 'function') { + * try { globalThis.__NS_HMR_REGISTER_COMPONENT__( + * 'ResourceModalComponent', ResourceModalComponent, import.meta.url + * ); } catch {} + * } + * + * 2. This module installs `__NS_HMR_REGISTER_COMPONENT__` on + * `globalThis` so module re-evaluations after an HMR reboot + * replace the previously-registered class with the fresh one. + * + * 3. HMR helpers (modal restore, route replay) read the registry + * via {@link getFreshComponentClass} to swap in the fresh class. + * + * This avoids patching `ɵɵdefineComponent` directly, which is exported + * as an immutable ESM namespace binding from `@angular/core` — patch + * attempts silently fail (the assignment is a no-op under strict + * mode) so the registry never gets populated. With the self- + * registration approach the binding stays untouched and we don't + * depend on Angular's internal export shape. + * + * Production short-circuit: the registrar is only installed when + * {@link isAngularHmrEnabled} reports dev + (vite | webpack). In a + * production build the global hook is never assigned and the Vite + * plugin only runs in `apply: 'serve'`, so the registration calls + * never reach the runtime. + */ + +import { isAngularDevMode, isAngularHmrEnabled } from './hmr-environment'; + +const REGISTRY_KEY = '__NS_ANGULAR_HMR_CLASS_REGISTRY__'; +const REGISTRY_META_KEY = '__NS_ANGULAR_HMR_CLASS_META__'; +const REGISTRAR_HOOK = '__NS_HMR_REGISTER_COMPONENT__'; +const REGISTRAR_INSTALLED_FLAG = '__NS_ANGULAR_HMR_REGISTRAR_INSTALLED__'; + +/** + * Diagnostic: counters that survive across HMR cycles via globalThis. + * Used to spot patterns like "the same class registered N times in a + * single cycle" or "a brand-new class object every cycle". + */ +const DIAG_KEY = '__NS_HMR_DIAG__'; +interface DiagSlot { + [DIAG_KEY]?: { + cycle: number; + registerCalls: number; + classIdentities: Map>; + classRegisterCounts: Map; + /** A short id assigned the first time we see a given class object. */ + classIds: WeakMap; + classIdNext: number; + }; +} +function getDiag() { + const slot = globalThis as unknown as DiagSlot; + if (!slot[DIAG_KEY]) { + slot[DIAG_KEY] = { + cycle: 0, + registerCalls: 0, + classIdentities: new Map(), + classRegisterCounts: new Map(), + classIds: new WeakMap(), + classIdNext: 1, + }; + } + return slot[DIAG_KEY]!; +} +/** Get/assign a short stable id for a class object. */ +function getClassId(diag: ReturnType, cls: object): string { + let id = diag.classIds.get(cls); + if (!id) { + id = `c${diag.classIdNext++}`; + diag.classIds.set(cls, id); + } + return id; +} +function diagLog(message: string): void { + if (!isAngularHmrEnabled()) return; + console.info(`[ns-hmr-diag][class-registry] ${message}`); +} +/** + * Log helper that uses {@link isAngularDevMode} instead of + * {@link isAngularHmrEnabled} so it fires for messages that *must* be + * visible at module-load time, before NativeScript Vite's HMR globals + * have been set. The HMR-globals check would otherwise suppress the + * "registrar installed" message in the same window we're trying to + * diagnose. Production builds (`ngDevMode === false`) still skip the + * log. + */ +function bootLog(message: string): void { + if (!isAngularDevMode()) return; + console.info(`[ns-hmr-diag][class-registry] ${message}`); +} +/** + * Public so callers from application.ts can bump the cycle counter when + * a new HMR reboot starts. Kept as a free function (not a class method) + * to avoid forcing more imports on the application module. + * + * Self-heal: by the time we hit cycle bump, dev/HMR globals are + * definitely set (`__NS_HMR_BOOT_COMPLETE__` was set before the first + * HMR cycle ever runs). Some module-load orderings end up with + * `installAngularHmrComponentRegistrar()` called before the early + * placeholder global was set, so the registrar would have returned + * early and never installed the hook. Re-attempting here closes that + * window — `installAngularHmrComponentRegistrar()` is idempotent. + */ +export function _hmrDiagBumpCycle(): number { + const diag = getDiag(); + diag.cycle += 1; + diagLog(`---- cycle ${diag.cycle} start ----`); + installAngularHmrComponentRegistrar(); + return diag.cycle; +} +/** Public for tests. */ +export function _hmrDiagSnapshot(): { cycle: number; registerCalls: number; namesSeen: number } { + const diag = getDiag(); + return { + cycle: diag.cycle, + registerCalls: diag.registerCalls, + namesSeen: diag.classIdentities.size, + }; +} + +/** Shape of the global registry. Exposed for tests; runtime callers use the helpers below. */ +export type HmrClassRegistry = Map; + +/** Optional metadata kept alongside each registered class — currently the source `import.meta.url`. */ +export interface HmrClassMeta { + /** `import.meta.url` of the module that registered the class, or empty string. */ + url: string; + /** Cycle the registration most recently happened in. */ + cycle: number; +} +export type HmrClassMetaRegistry = Map; + +interface GlobalRegistrySlot { + [REGISTRY_KEY]?: HmrClassRegistry; + [REGISTRY_META_KEY]?: HmrClassMetaRegistry; + [REGISTRAR_INSTALLED_FLAG]?: boolean; + [REGISTRAR_HOOK]?: (name: string, cls: unknown, url?: string) => void; +} + +function getRegistry(): HmrClassRegistry { + const slot = globalThis as unknown as GlobalRegistrySlot; + let registry = slot[REGISTRY_KEY]; + if (!registry) { + registry = new Map(); + slot[REGISTRY_KEY] = registry; + } + return registry; +} + +function getMetaRegistry(): HmrClassMetaRegistry { + const slot = globalThis as unknown as GlobalRegistrySlot; + let registry = slot[REGISTRY_META_KEY]; + if (!registry) { + registry = new Map(); + slot[REGISTRY_META_KEY] = registry; + } + return registry; +} + +/** + * Internal: write a class into the registry. Exposed for unit tests + * (which can call this directly to simulate a Vite-injected + * registration without spinning up the Vite plugin pipeline). + * + * Production callers should never need this — user code just calls + * the global `__NS_HMR_REGISTER_COMPONENT__` hook installed by + * {@link installAngularHmrComponentRegistrar}. + */ +export function _registerComponentForHmr(name: string, cls: unknown, url = ''): void { + if (!name || typeof name !== 'string') return; + if (cls === undefined || cls === null) return; + + const registry = getRegistry(); + const meta = getMetaRegistry(); + const previous = registry.get(name); + registry.set(name, cls); + meta.set(name, { url: url || '', cycle: getDiag().cycle }); + + const d = getDiag(); + d.registerCalls += 1; + if (typeof cls === 'object' || typeof cls === 'function') { + const classId = getClassId(d, cls as object); + let identitySet = d.classIdentities.get(name); + if (!identitySet) { + identitySet = new Set(); + d.classIdentities.set(name, identitySet); + } + identitySet.add(cls); + d.classRegisterCounts.set(name, (d.classRegisterCounts.get(name) ?? 0) + 1); + + // Only log a small set of "interesting" components to keep noise + // manageable. Verbose mode is enabled by setting + // globalThis.__NS_HMR_DIAG_VERBOSE = true (e.g. from the user + // app's main.ts) when we want all names. + const verbose = !!(globalThis as { __NS_HMR_DIAG_VERBOSE?: boolean }).__NS_HMR_DIAG_VERBOSE; + const watchPattern = (globalThis as { __NS_HMR_DIAG_WATCH?: RegExp | string }).__NS_HMR_DIAG_WATCH; + const matches = watchPattern instanceof RegExp ? watchPattern.test(name) : typeof watchPattern === 'string' ? name.includes(watchPattern) : /Modal|Dialog/.test(name); + if (verbose || matches) { + diagLog( + `register name=${name} classId=${classId} sameAsPrev=${previous === cls} cycle=${d.cycle} totalIdentitiesForName=${identitySet.size} registerCountForName=${d.classRegisterCounts.get(name)} url=${url || '(none)'}`, + ); + } + } +} + +/** + * Install the cross-module `__NS_HMR_REGISTER_COMPONENT__` hook on + * `globalThis`. The Vite plugin `ns-component-hmr-register` injects + * a call to this hook at the end of every user `.ts` file that + * defines an `@Component`-decorated class, so re-evaluations after + * an HMR reboot keep the registry pointed at the live class. + * + * Idempotent: calling twice is a no-op (the second call sees the + * installed flag and returns). + * + * The hook is installed unconditionally — it's a single function + * reference on globalThis with negligible cost. Production builds + * never reach this code path because the Vite plugin + * `ns-component-hmr-register` runs only with `apply: 'serve'`, so + * the hook is never *called* in production. We previously gated + * this on `isAngularHmrEnabled()` but that check depends on + * NativeScript Vite globals (`__NS_DEV_PLACEHOLDER_ROOT_EARLY__` / + * `__NS_HMR_BOOT_COMPLETE__`) that are set imperatively from + * `main-entry.ts`. Module-load ordering can put `application.ts` + * evaluation *before* those globals are set in some startup paths, + * causing the install to silently no-op while the Vite plugin + * happily emits registration calls into user `.ts` files. The end + * result was an empty registry and `getFreshComponentClass` always + * reporting `reason=no-registry`. Removing the gate eliminates + * that race entirely. + */ +export function installAngularHmrComponentRegistrar(): void { + const slot = globalThis as unknown as GlobalRegistrySlot; + if (slot[REGISTRAR_INSTALLED_FLAG]) { + return; + } + + // Define the hook BEFORE marking installed so concurrent module + // initializers see the function as soon as the flag is observable. + slot[REGISTRAR_HOOK] = (name: string, cls: unknown, url?: string) => { + try { + _registerComponentForHmr(name, cls, typeof url === 'string' ? url : ''); + } catch (err) { + // Registration is best-effort — never break a user module load + // because of a registration failure. + diagLog(`registrar threw for ${name}: ${(err as Error)?.message ?? err}`); + } + }; + slot[REGISTRAR_INSTALLED_FLAG] = true; + + bootLog('installAngularHmrComponentRegistrar installed global hook __NS_HMR_REGISTER_COMPONENT__'); +} + +/** + * Look up the freshest registered component class for a given name, or + * `undefined` if no match. HMR helpers (e.g. dialog restore) call this + * with the captured class's `.name` to find the live class after a + * reboot. Returns `undefined` in production builds because the registrar + * is never installed there. + */ +export function getFreshComponentClass(name: string): T | undefined { + if (!name) return undefined; + const slot = globalThis as unknown as GlobalRegistrySlot; + const registry = slot[REGISTRY_KEY]; + // Diagnostics: log every lookup with the resolved class id so the + // restore path can be cross-referenced against register emissions. + if (registry) { + const value = registry.get(name) as T | undefined; + const diag = getDiag(); + const classId = value && (typeof value === 'object' || typeof value === 'function') ? getClassId(diag, value as unknown as object) : '(none)'; + const knownNames = registry.size; + diagLog(`getFreshComponentClass name=${name} found=${!!value} classId=${classId} registrySize=${knownNames}`); + return value; + } + diagLog(`getFreshComponentClass name=${name} found=false reason=no-registry`); + return undefined; +} + +/** + * Look up the source URL (`import.meta.url`) recorded for a registered + * component. Used by HMR helpers that need to force a fresh import of + * a lazily-loaded module (e.g. modals whose static import chain doesn't + * walk the bootstrap path). + * + * Returns `undefined` if the name was never registered or if no URL was + * provided at registration time. + */ +export function getRegisteredComponentUrl(name: string): string | undefined { + if (!name) return undefined; + const slot = globalThis as unknown as GlobalRegistrySlot; + const meta = slot[REGISTRY_META_KEY]; + const entry = meta?.get(name); + return entry?.url || undefined; +} + +/** + * Test/debug helper: clear all registered classes. Production callers + * never need this; the registry stays empty without the registrar. + */ +export function clearAngularHmrClassRegistry(): void { + const slot = globalThis as unknown as GlobalRegistrySlot; + slot[REGISTRY_KEY] = undefined; + slot[REGISTRY_META_KEY] = undefined; + slot[REGISTRAR_INSTALLED_FLAG] = undefined; + slot[REGISTRAR_HOOK] = undefined; + // Also reset diag so test isolation isn't broken by counts leaking. + const diagSlot = globalThis as unknown as DiagSlot; + diagSlot[DIAG_KEY] = undefined; +} diff --git a/packages/angular/src/lib/hmr-eager-services.spec.ts b/packages/angular/src/lib/hmr-eager-services.spec.ts new file mode 100644 index 00000000..5a92ea75 --- /dev/null +++ b/packages/angular/src/lib/hmr-eager-services.spec.ts @@ -0,0 +1,155 @@ +import { + clearHmrEagerInstantiators, + getRegisteredHmrEagerInstantiators, + HmrEagerInstantiator, + installHmrEagerRegistrar, + registerHmrEagerInstantiator, + runHmrEagerInstantiators, +} from './hmr-eager-services'; + +const REGISTRY_KEY = '__NS_HMR_EAGER_SERVICES__'; +const REGISTER_KEY = '__NS_REGISTER_HMR_EAGER_SERVICE__'; + +interface TestGlobals { + [REGISTRY_KEY]?: HmrEagerInstantiator[]; + [REGISTER_KEY]?: ((fn: HmrEagerInstantiator) => void) | undefined; +} + +function getTestGlobals(): TestGlobals { + return globalThis as unknown as TestGlobals; +} + +describe('HMR eager-services registry', () => { + beforeEach(() => { + // Reset the global state between specs so each test starts from a + // clean slate. We can't `delete` from `globalThis` reliably across + // engines, but emptying the array + clearing the registrar gives the + // same observable behavior to the helpers under test. + clearHmrEagerInstantiators(); + getTestGlobals()[REGISTER_KEY] = undefined; + }); + + describe('registerHmrEagerInstantiator', () => { + it('appends the callback and returns true when newly registered', () => { + const fn: HmrEagerInstantiator = jest.fn(); + + const wasAdded = registerHmrEagerInstantiator(fn); + + expect(wasAdded).toBe(true); + expect(getRegisteredHmrEagerInstantiators()).toContain(fn); + }); + + it('is idempotent: registering the same function twice keeps a single entry', () => { + const fn: HmrEagerInstantiator = jest.fn(); + + registerHmrEagerInstantiator(fn); + const wasAddedAgain = registerHmrEagerInstantiator(fn); + + expect(wasAddedAgain).toBe(false); + expect(getRegisteredHmrEagerInstantiators().filter((entry) => entry === fn)).toHaveLength(1); + }); + + it('refuses non-function values so a buggy globalThis cast cannot poison the registry', () => { + const wasAdded = registerHmrEagerInstantiator('not a function' as unknown as HmrEagerInstantiator); + + expect(wasAdded).toBe(false); + expect(getRegisteredHmrEagerInstantiators()).toHaveLength(0); + }); + }); + + describe('runHmrEagerInstantiators', () => { + it('invokes every registered callback with the bootstrapped injector', () => { + const calls: unknown[] = []; + const fn1: HmrEagerInstantiator = (injector) => calls.push(['fn1', injector]); + const fn2: HmrEagerInstantiator = (injector) => calls.push(['fn2', injector]); + registerHmrEagerInstantiator(fn1); + registerHmrEagerInstantiator(fn2); + + const injector = { id: 'fake-injector' } as unknown as Parameters[0]; + runHmrEagerInstantiators(injector); + + expect(calls).toEqual([ + ['fn1', injector], + ['fn2', injector], + ]); + }); + + it('continues running remaining callbacks when one throws', () => { + const survivors: string[] = []; + const errors: unknown[] = []; + registerHmrEagerInstantiator(() => { + survivors.push('first'); + }); + registerHmrEagerInstantiator(() => { + throw new Error('boom'); + }); + registerHmrEagerInstantiator(() => { + survivors.push('third'); + }); + + runHmrEagerInstantiators({} as Parameters[0], (err) => errors.push(err)); + + expect(survivors).toEqual(['first', 'third']); + expect(errors).toHaveLength(1); + expect((errors[0] as Error).message).toBe('boom'); + }); + + it('ignores errors thrown by the onError reporter so a logging bug does not abort the loop', () => { + const survivors: string[] = []; + registerHmrEagerInstantiator(() => { + throw new Error('first failure'); + }); + registerHmrEagerInstantiator(() => { + survivors.push('second ran'); + }); + + expect(() => + runHmrEagerInstantiators({} as Parameters[0], () => { + throw new Error('reporter blew up'); + }), + ).not.toThrow(); + + expect(survivors).toEqual(['second ran']); + }); + + it('is a safe no-op when the injector is missing or the registry is empty', () => { + expect(() => runHmrEagerInstantiators(undefined)).not.toThrow(); + expect(() => runHmrEagerInstantiators(null)).not.toThrow(); + + registerHmrEagerInstantiator(jest.fn()); + expect(() => runHmrEagerInstantiators(undefined)).not.toThrow(); + }); + }); + + describe('installHmrEagerRegistrar', () => { + it('installs a global hook that delegates to registerHmrEagerInstantiator', () => { + installHmrEagerRegistrar(); + + const hook = getTestGlobals()[REGISTER_KEY]; + expect(typeof hook).toBe('function'); + + const fn: HmrEagerInstantiator = jest.fn(); + hook?.(fn); + + expect(getRegisteredHmrEagerInstantiators()).toContain(fn); + }); + + it('is idempotent across multiple calls so HMR re-evaluations do not replace the live hook', () => { + installHmrEagerRegistrar(); + const first = getTestGlobals()[REGISTER_KEY]; + + installHmrEagerRegistrar(); + const second = getTestGlobals()[REGISTER_KEY]; + + expect(second).toBe(first); + }); + + it('the global hook ignores non-function arguments', () => { + installHmrEagerRegistrar(); + const hook = getTestGlobals()[REGISTER_KEY]; + + expect(() => hook?.(undefined as unknown as HmrEagerInstantiator)).not.toThrow(); + expect(getRegisteredHmrEagerInstantiators()).toHaveLength(0); + }); + }); +}); diff --git a/packages/angular/src/lib/hmr-eager-services.ts b/packages/angular/src/lib/hmr-eager-services.ts new file mode 100644 index 00000000..7ae73368 --- /dev/null +++ b/packages/angular/src/lib/hmr-eager-services.ts @@ -0,0 +1,150 @@ +import type { Injector } from '@angular/core'; + +/** + * Registry for HMR-aware services that need to be eagerly instantiated + * after each Angular bootstrap so they can attach long-lived subscriptions + * (e.g. to `postAngularBootstrap$`) before the user's app code starts + * interacting with the new module realm. + * + * The registry lives on `globalThis` so consumers in different module + * realms — including pre-bundled vendor copies of this package — can + * register the same callback set without depending on import order. The + * registry is **idempotent**: registering the same function reference + * twice is a no-op. + * + * Production usage is gated by callers (see `dialog-services.ts`'s + * `isAngularHmrEnabled()` check) so registrations never accumulate in + * shipping builds. + * + * Failure handling: `runHmrEagerInstantiators` swallows per-callback + * exceptions intentionally. A buggy registrant must never abort + * bootstrap. The handler can return diagnostics via the optional + * `onError` parameter so the application module can route failures to + * the bootstrap log channel instead of silently dropping them. + */ +export type HmrEagerInstantiator = (injector: Injector) => void; + +const REGISTRY_KEY = '__NS_HMR_EAGER_SERVICES__'; +const REGISTER_KEY = '__NS_REGISTER_HMR_EAGER_SERVICE__'; + +/** + * Diagnostic: gate logging behind the same dev-only flag callers + * already use to decide whether to register at all. Production + * registrations are no-ops, so production log paths stay silent. + */ +function eagerDiag(message: string): void { + // We can't import isAngularHmrEnabled here without creating a + // circular import (hmr-eager-services <- dialog-services <- + // application <- hmr-eager-services). Instead, key off the same + // globals isAngularHmrEnabled checks (kept inline for layering). + const g = globalThis as { __NS_DEV_PLACEHOLDER_ROOT_EARLY__?: unknown; __NS_HMR_BOOT_COMPLETE__?: unknown; ngDevMode?: boolean }; + const ngDev = (typeof g.ngDevMode === 'boolean') ? g.ngDevMode : true; + const viteHmr = !!g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__ || !!g.__NS_HMR_BOOT_COMPLETE__; + if (!(ngDev && viteHmr)) return; + console.info(`[ns-hmr-diag][eager] ${message}`); +} + +interface HmrEagerGlobals { + [REGISTRY_KEY]?: HmrEagerInstantiator[]; + [REGISTER_KEY]?: (fn: HmrEagerInstantiator) => void; +} + +function getStore(): HmrEagerGlobals { + return globalThis as unknown as HmrEagerGlobals; +} + +/** + * Returns the live (mutable) array of registered eager instantiators. + * Callers must not mutate it directly outside of the helpers in this + * module — use {@link registerHmrEagerInstantiator} or + * {@link clearHmrEagerInstantiators} instead. + */ +export function getRegisteredHmrEagerInstantiators(): HmrEagerInstantiator[] { + const store = getStore(); + const list = store[REGISTRY_KEY]; + if (!Array.isArray(list)) { + const fresh: HmrEagerInstantiator[] = []; + store[REGISTRY_KEY] = fresh; + return fresh; + } + return list; +} + +/** + * Idempotently register an instantiator callback. Returns `true` when the + * callback was added, `false` when it was already present. + */ +export function registerHmrEagerInstantiator(fn: HmrEagerInstantiator): boolean { + if (typeof fn !== 'function') { + return false; + } + const list = getRegisteredHmrEagerInstantiators(); + if (list.includes(fn)) { + eagerDiag(`registerHmrEagerInstantiator dedup (already present) listSize=${list.length}`); + return false; + } + list.push(fn); + eagerDiag(`registerHmrEagerInstantiator added newSize=${list.length} fnName=${fn.name || '(anon)'}`); + return true; +} + +/** + * Clear all registered instantiators. Tests use this to reset state + * between specs; production code should not call it. + */ +export function clearHmrEagerInstantiators(): void { + const list = getRegisteredHmrEagerInstantiators(); + list.length = 0; +} + +/** + * Install the cross-module registration entry point on `globalThis` so + * consumer modules (e.g. `dialog-services.ts`) can register without + * statically importing this file. Idempotent across multiple calls so + * application.ts can call it on every reboot without leaking state. + */ +export function installHmrEagerRegistrar(): void { + const store = getStore(); + if (typeof store[REGISTER_KEY] === 'function') { + return; + } + store[REGISTER_KEY] = (fn: HmrEagerInstantiator) => { + registerHmrEagerInstantiator(fn); + }; +} + +/** + * Invoke every registered instantiator with the bootstrapped injector. + * Per-callback exceptions are swallowed; pass `onError` to receive them + * for logging. + */ +export function runHmrEagerInstantiators( + injector: Injector | null | undefined, + onError?: (err: unknown) => void, +): void { + if (!injector) { + eagerDiag(`runHmrEagerInstantiators called without injector — no-op`); + return; + } + const list = getRegisteredHmrEagerInstantiators(); + eagerDiag(`runHmrEagerInstantiators list.length=${list.length}`); + if (list.length === 0) { + return; + } + for (let i = 0; i < list.length; i++) { + const fn = list[i]; + try { + eagerDiag(`runHmrEagerInstantiators calling [${i}] ${fn.name || '(anon)'}`); + fn(injector); + } catch (err) { + eagerDiag(`runHmrEagerInstantiators [${i}] threw: ${(err as Error)?.message ?? err}`); + if (onError) { + try { + onError(err); + } catch { + // The error reporter must not itself break the loop. + } + } + } + } +} diff --git a/packages/angular/src/lib/hmr-environment.spec.ts b/packages/angular/src/lib/hmr-environment.spec.ts new file mode 100644 index 00000000..ea83131c --- /dev/null +++ b/packages/angular/src/lib/hmr-environment.spec.ts @@ -0,0 +1,181 @@ +import { isAngularDevMode, isAngularHmrEnabled, isNativeScriptViteHmrActive, isWebpackHmrActive } from './hmr-environment'; + +interface MutableGlobal { + ngDevMode?: boolean; + __NS_DEV_PLACEHOLDER_ROOT_EARLY__?: unknown; + __NS_HMR_BOOT_COMPLETE__?: unknown; + __webpack_require__?: unknown; +} + +describe('hmr-environment', () => { + const g = globalThis as unknown as MutableGlobal; + + // We snapshot/restore globals so each spec runs in isolation. The test + // runner does not control whether `ngDevMode` is defined, so we record + // its descriptor before mutating. + let originalNgDevModeDefined = false; + let originalNgDevModeValue: boolean | undefined; + let originalPlaceholderFlag: unknown; + let originalBootCompleteFlag: unknown; + let originalWebpackRequire: unknown; + + beforeEach(() => { + originalNgDevModeDefined = Object.prototype.hasOwnProperty.call(g, 'ngDevMode'); + originalNgDevModeValue = g.ngDevMode; + originalPlaceholderFlag = g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__; + originalBootCompleteFlag = g.__NS_HMR_BOOT_COMPLETE__; + originalWebpackRequire = g.__webpack_require__; + }); + + afterEach(() => { + if (originalNgDevModeDefined) { + g.ngDevMode = originalNgDevModeValue; + } else { + delete g.ngDevMode; + } + if (originalPlaceholderFlag === undefined) { + delete g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__; + } else { + g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__ = originalPlaceholderFlag; + } + if (originalBootCompleteFlag === undefined) { + delete g.__NS_HMR_BOOT_COMPLETE__; + } else { + g.__NS_HMR_BOOT_COMPLETE__ = originalBootCompleteFlag; + } + if (originalWebpackRequire === undefined) { + delete g.__webpack_require__; + } else { + g.__webpack_require__ = originalWebpackRequire; + } + }); + + describe('isAngularDevMode', () => { + it('treats undefined ngDevMode as dev (Angular convention)', () => { + delete g.ngDevMode; + expect(isAngularDevMode()).toBe(true); + }); + + it('returns false when Angular built in production mode', () => { + g.ngDevMode = false; + expect(isAngularDevMode()).toBe(false); + }); + + it('returns true when ngDevMode is explicitly truthy', () => { + g.ngDevMode = true; + expect(isAngularDevMode()).toBe(true); + }); + }); + + describe('isNativeScriptViteHmrActive', () => { + it('returns false when neither NS Vite dev flag is set', () => { + delete g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__; + delete g.__NS_HMR_BOOT_COMPLETE__; + + expect(isNativeScriptViteHmrActive()).toBe(false); + }); + + it('returns true while the early-boot placeholder flag is still set', () => { + // Reproduces the window between root-placeholder install and + // tryFinalizeBootPlaceholder finishing. Services constructed during + // bootstrap (route tracker, etc.) hit this path. + g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__ = true; + delete g.__NS_HMR_BOOT_COMPLETE__; + + expect(isNativeScriptViteHmrActive()).toBe(true); + }); + + it('returns true after the placeholder is cleared and boot-complete flag is set', () => { + // Reproduces the typical late-construction window — e.g. NativeDialog + // is instantiated lazily on first modal open, by which point the NS + // Vite root-placeholder runtime has already deleted the early flag + // and set the persistent boot-complete flag. + delete g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__; + g.__NS_HMR_BOOT_COMPLETE__ = true; + + expect(isNativeScriptViteHmrActive()).toBe(true); + }); + + it('returns true when both flags are concurrently set', () => { + // Defensive: a future runtime could leave both set briefly during + // the finalize transition. The OR guarantees we never miss it. + g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__ = true; + g.__NS_HMR_BOOT_COMPLETE__ = true; + + expect(isNativeScriptViteHmrActive()).toBe(true); + }); + }); + + describe('isWebpackHmrActive', () => { + it('reads the webpack runtime function on globalThis', () => { + delete g.__webpack_require__; + expect(isWebpackHmrActive()).toBe(false); + + g.__webpack_require__ = () => undefined; + expect(isWebpackHmrActive()).toBe(true); + }); + + it('treats a non-function value on globalThis.__webpack_require__ as inactive', () => { + g.__webpack_require__ = 'not-a-function' as unknown as object; + expect(isWebpackHmrActive()).toBe(false); + }); + }); + + describe('isAngularHmrEnabled', () => { + it('returns false in production builds even when bundler signals slip through', () => { + g.ngDevMode = false; + g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__ = true; + g.__NS_HMR_BOOT_COMPLETE__ = true; + g.__webpack_require__ = () => undefined; + + expect(isAngularHmrEnabled()).toBe(false); + }); + + it('returns true in dev when the NS Vite early placeholder flag is set', () => { + g.ngDevMode = true; + g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__ = true; + delete g.__NS_HMR_BOOT_COMPLETE__; + delete g.__webpack_require__; + + expect(isAngularHmrEnabled()).toBe(true); + }); + + it('returns true in dev when only the post-boot complete flag is set (late instantiation case)', () => { + // Regression test for the bug that caused modal HMR and route replay + // to silently no-op: services injected after the placeholder commits + // (e.g. NativeDialog on first modal open) saw the early flag deleted + // and would previously decide HMR was disabled. + g.ngDevMode = true; + delete g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__; + g.__NS_HMR_BOOT_COMPLETE__ = true; + delete g.__webpack_require__; + + expect(isAngularHmrEnabled()).toBe(true); + }); + + it('returns true in dev when webpack HMR is active even without the Vite flag', () => { + g.ngDevMode = true; + delete g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__; + delete g.__NS_HMR_BOOT_COMPLETE__; + g.__webpack_require__ = () => undefined; + + expect(isAngularHmrEnabled()).toBe(true); + }); + + it('returns false in dev when no bundler HMR signal is present', () => { + g.ngDevMode = true; + delete g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__; + delete g.__NS_HMR_BOOT_COMPLETE__; + delete g.__webpack_require__; + + expect(isAngularHmrEnabled()).toBe(false); + }); + + it('treats undefined ngDevMode as dev so unit tests still see the gate working', () => { + delete g.ngDevMode; + g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__ = true; + + expect(isAngularHmrEnabled()).toBe(true); + }); + }); +}); diff --git a/packages/angular/src/lib/hmr-environment.ts b/packages/angular/src/lib/hmr-environment.ts new file mode 100644 index 00000000..29ecbf5a --- /dev/null +++ b/packages/angular/src/lib/hmr-environment.ts @@ -0,0 +1,98 @@ +/** + * Centralised dev-mode + HMR detection for `@nativescript/angular` helpers. + * + * The package ships HMR scaffolding (route tracker, route replay, modal + * preservation, compiled-component reset) that subscribes to long-lived + * router and bootstrap streams. None of that work belongs in a production + * binary — it would attach observers that never fire and keep references + * that confuse Angular's destroy logic. + * + * Every HMR helper consults {@link isAngularHmrEnabled} from its + * constructor. The check is intentionally cheap (no network, no I/O) so it + * is safe to call in dependency-injection factories and in fast paths. + * + * Detection cascade (returns the first match): + * 1. **Production build short-circuit** — `ngDevMode === false` means + * Angular built the app in production mode. We bail immediately. + * 2. **NativeScript Vite dev signal** — see + * {@link isNativeScriptViteHmrActive}. We accept either of the two + * persistent globals the NS Vite root-placeholder installer manages + * (`__NS_DEV_PLACEHOLDER_ROOT_EARLY__` during early boot, + * `__NS_HMR_BOOT_COMPLETE__` after the real app root commits) so + * services that are constructed *after* the placeholder has handed + * off — e.g. `NativeDialog` instantiated lazily when the user opens + * their first modal — still detect HMR correctly. + * 3. **Webpack HMR signal** — `globalThis.__webpack_require__` is set + * when the webpack runtime is loaded. Combined with the `ngDevMode` + * short-circuit above, its presence means "webpack dev". The + * production webpack runtime also sets the global, but `ngDevMode` + * would already be `false`, so the production case never reaches + * here. + * + * If none of these match, the caller should treat HMR as disabled and + * skip subscribing to disposal/bootstrap streams. + * + * The webpack signal lives on `globalThis` rather than `import.meta` so + * this file compiles cleanly under `--module commonjs` (the jest spec + * compiler) and under `--module esnext` (the library build). + */ + +declare const ngDevMode: boolean | undefined; + +export function isAngularHmrEnabled(): boolean { + if (typeof ngDevMode !== 'undefined' && ngDevMode === false) { + return false; + } + return isNativeScriptViteHmrActive() || isWebpackHmrActive(); +} + +/** + * True when the NativeScript Vite dev HMR runtime is active. This is the + * most reliable signal that the project's `nativescript.config.ts` set + * `bundler: 'vite'` AND we are running the dev server. + * + * The NS Vite root-placeholder installer manages two persistent globals: + * - `__NS_DEV_PLACEHOLDER_ROOT_EARLY__` is set the moment the placeholder + * runs (very early, before the real app boots), then **deleted** by + * `clearPlaceholderGlobals` once `tryFinalizeBootPlaceholder` succeeds. + * - `__NS_HMR_BOOT_COMPLETE__` is set in the same finalize step and is + * **never deleted** for the lifetime of the dev session. + * + * Callers run the gamut of timing — e.g. the route tracker is constructed + * during bootstrap (early flag still set) but `NativeDialog` is typically + * instantiated lazily when the user opens their first modal (early flag + * already cleared, complete flag set). Checking either global covers both + * windows. If we only checked the early flag, every late-instantiated + * service would silently no-op and HMR features (modal preservation, + * route replay) would appear broken in development. + */ +export function isNativeScriptViteHmrActive(): boolean { + const g = globalThis as { + __NS_DEV_PLACEHOLDER_ROOT_EARLY__?: unknown; + __NS_HMR_BOOT_COMPLETE__?: unknown; + }; + return !!(g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__ || g.__NS_HMR_BOOT_COMPLETE__); +} + +/** + * True when the webpack runtime is loaded. The webpack runtime sets + * `__webpack_require__` on `globalThis` whenever a webpack bundle is + * executing — both in dev and prod. Callers gate on + * {@link isAngularHmrEnabled} (not this directly) so the production + * short-circuit fires first. + */ +export function isWebpackHmrActive(): boolean { + return typeof (globalThis as { __webpack_require__?: unknown }).__webpack_require__ === 'function'; +} + +/** + * True when Angular reports we are running with dev-mode flags. Useful + * for code paths that want to opt out of cost in production but don't + * care which bundler is running. + */ +export function isAngularDevMode(): boolean { + if (typeof ngDevMode === 'undefined') { + return true; + } + return ngDevMode !== false; +} diff --git a/packages/angular/src/lib/legacy/router/hmr-route-bootstrap.spec.ts b/packages/angular/src/lib/legacy/router/hmr-route-bootstrap.spec.ts index d6ff56ad..8e790528 100644 --- a/packages/angular/src/lib/legacy/router/hmr-route-bootstrap.spec.ts +++ b/packages/angular/src/lib/legacy/router/hmr-route-bootstrap.spec.ts @@ -26,7 +26,7 @@ describe('cloneRoutesForBootstrap', () => { }, ] as any; - const cloned = cloneRoutesForBootstrap(routes); + const cloned = cloneRoutesForBootstrap(routes); expect(cloned).not.toBe(routes); expect(cloned[0]).not.toBe(routes[0]); diff --git a/packages/angular/src/lib/legacy/router/hmr-route-replay.spec.ts b/packages/angular/src/lib/legacy/router/hmr-route-replay.spec.ts new file mode 100644 index 00000000..a890d066 --- /dev/null +++ b/packages/angular/src/lib/legacy/router/hmr-route-replay.spec.ts @@ -0,0 +1,259 @@ +jest.mock('@angular/core', () => ({ + Injectable: () => (target: unknown) => target, +})); + +class MockNavigationEnd { + constructor( + public id: number, + public url: string, + public urlAfterRedirects: string, + ) {} +} +class MockNavigationCancel { + constructor( + public id: number, + public url: string, + public reason: string, + ) {} +} +class MockNavigationError { + constructor( + public id: number, + public url: string, + public error: unknown, + ) {} +} + +jest.mock('@angular/router', () => ({ + NavigationEnd: MockNavigationEnd, + NavigationCancel: MockNavigationCancel, + NavigationError: MockNavigationError, + Router: class {}, +})); + +jest.mock('../../trace', () => ({ + NativeScriptDebug: { + isLogEnabled: () => false, + hmrLog: jest.fn(), + }, +})); + +jest.mock('../../hmr-environment', () => ({ + isAngularHmrEnabled: () => true, +})); + +import { Subject } from 'rxjs'; + +import { + beginAngularHmrRouteRestore, + clearAngularHmrPendingRouteHistory, + clearAngularHmrRouteHistory, + endAngularHmrRouteRestore, + isAngularHmrRestoringRoute, + pushAngularHmrRouteHistoryEntry, + readAngularHmrPendingForwardNavigations, + readAngularHmrPendingRouteHistory, + readAngularHmrPendingStartPath, + snapshotAngularHmrRouteHistory, +} from './hmr-route-state-core'; +import { NativeScriptAngularHmrRouteReplay } from './hmr-route-replay'; + +interface RouterEvent { + url?: string; +} + +interface RouterMock { + events: Subject; + navigateByUrl: jest.Mock, [string]>; + emitNavigationEnd(url: string): void; + emitNavigationCancel(url: string): void; + emitNavigationError(url: string): void; +} + +function createRouterMock(): RouterMock { + const events = new Subject(); + const navigateByUrl = jest.fn, [string]>(() => Promise.resolve(true)); + return { + events, + navigateByUrl, + emitNavigationEnd(url: string) { + events.next(new MockNavigationEnd(1, url, url) as unknown as RouterEvent); + }, + emitNavigationCancel(url: string) { + events.next(new MockNavigationCancel(1, url, 'cancel') as unknown as RouterEvent); + }, + emitNavigationError(url: string) { + events.next(new MockNavigationError(1, url, new Error('boom')) as unknown as RouterEvent); + }, + }; +} + +/** + * Drain the microtask queue so awaited statements inside + * `replayForwardNavigations` make progress. Jest's modern fake timers + * fake `setImmediate` and `process.nextTick` but leave the JS engine's + * Promise microtask queue alone, so chaining `await Promise.resolve()` + * is the simplest way to push the replay through one `await` boundary + * at a time. + */ +async function flushMicrotasks(rounds = 10): Promise { + for (let i = 0; i < rounds; i++) { + await Promise.resolve(); + } +} + +describe('NativeScriptAngularHmrRouteReplay', () => { + beforeEach(() => { + jest.useFakeTimers(); + clearAngularHmrRouteHistory(); + clearAngularHmrPendingRouteHistory(); + endAngularHmrRouteRestore(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + clearAngularHmrRouteHistory(); + clearAngularHmrPendingRouteHistory(); + endAngularHmrRouteRestore(); + }); + + it('keeps the restoring window open during the grace period after a multi-URL replay completes', async () => { + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + pushAngularHmrRouteHistoryEntry('/profile'); + snapshotAngularHmrRouteHistory(); + + expect(readAngularHmrPendingStartPath()).toBe('/talk/(todayTab:today)'); + expect(readAngularHmrPendingForwardNavigations()).toEqual(['/profile']); + expect(isAngularHmrRestoringRoute()).toBe(true); + + const router = createRouterMock(); + const replay = new NativeScriptAngularHmrRouteReplay(router as any); + + router.emitNavigationEnd('/talk/(todayTab:today)'); + + await flushMicrotasks(); + await flushMicrotasks(); + + expect(router.navigateByUrl).toHaveBeenCalledWith('/profile'); + expect(readAngularHmrPendingRouteHistory()).toEqual([]); + // The replay finished but the grace period should still consider the + // window open so async (loaded) handlers can suppress default + // navigations. + expect(isAngularHmrRestoringRoute()).toBe(true); + + jest.advanceTimersByTime(999); + expect(isAngularHmrRestoringRoute()).toBe(true); + + jest.advanceTimersByTime(1); + expect(isAngularHmrRestoringRoute()).toBe(false); + + replay.ngOnDestroy(); + }); + + it('keeps the window open across the grace period when the replay aborts mid-stack', async () => { + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + pushAngularHmrRouteHistoryEntry('/profile'); + pushAngularHmrRouteHistoryEntry('/profile/edit'); + snapshotAngularHmrRouteHistory(); + + // `readAngularHmrPendingStartPath` is what opens the window in the + // real bootstrap flow (it's called from the START_PATH provider). + // The test mirrors that so the replay service has a window to keep + // open during the grace period. + expect(readAngularHmrPendingStartPath()).toBe('/talk/(todayTab:today)'); + expect(readAngularHmrPendingForwardNavigations()).toEqual(['/profile', '/profile/edit']); + expect(isAngularHmrRestoringRoute()).toBe(true); + + const router = createRouterMock(); + router.navigateByUrl.mockImplementation((url: string) => Promise.resolve(url === '/profile')); + + const replay = new NativeScriptAngularHmrRouteReplay(router as any); + router.emitNavigationEnd('/talk/(todayTab:today)'); + + await flushMicrotasks(); + await flushMicrotasks(); + await flushMicrotasks(); + + expect(router.navigateByUrl).toHaveBeenCalledWith('/profile'); + expect(router.navigateByUrl).toHaveBeenCalledWith('/profile/edit'); + expect(readAngularHmrPendingRouteHistory()).toEqual([]); + // Even when aborted, the grace period should still hold the window + // open so user-app guards see `true` until the deferred close fires. + expect(isAngularHmrRestoringRoute()).toBe(true); + + jest.advanceTimersByTime(1000); + expect(isAngularHmrRestoringRoute()).toBe(false); + + replay.ngOnDestroy(); + }); + + it('keeps the single-URL restore window open across the grace period after the initial NavigationEnd', () => { + beginAngularHmrRouteRestore('/profile?tab=goals'); + expect(isAngularHmrRestoringRoute()).toBe(true); + + const router = createRouterMock(); + const replay = new NativeScriptAngularHmrRouteReplay(router as any); + + router.emitNavigationEnd('/profile?tab=goals'); + + expect(isAngularHmrRestoringRoute()).toBe(true); + + jest.advanceTimersByTime(999); + expect(isAngularHmrRestoringRoute()).toBe(true); + + jest.advanceTimersByTime(1); + expect(isAngularHmrRestoringRoute()).toBe(false); + + replay.ngOnDestroy(); + }); + + it('clears the deferred close timer when the service is destroyed', async () => { + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + pushAngularHmrRouteHistoryEntry('/profile'); + snapshotAngularHmrRouteHistory(); + + expect(readAngularHmrPendingStartPath()).toBe('/talk/(todayTab:today)'); + expect(isAngularHmrRestoringRoute()).toBe(true); + + const router = createRouterMock(); + const replay = new NativeScriptAngularHmrRouteReplay(router as any); + + router.emitNavigationEnd('/talk/(todayTab:today)'); + await flushMicrotasks(); + await flushMicrotasks(); + + expect(isAngularHmrRestoringRoute()).toBe(true); + + replay.ngOnDestroy(); + + expect(isAngularHmrRestoringRoute()).toBe(false); + + // Make sure the deferred timer cannot fire later and toggle the + // window for a future bootstrap that has its own snapshot. + beginAngularHmrRouteRestore('/somewhere/else'); + jest.advanceTimersByTime(2000); + expect(isAngularHmrRestoringRoute()).toBe(true); + + endAngularHmrRouteRestore(); + }); + + it('closes the window immediately when the initial navigation fails (no grace period)', () => { + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + pushAngularHmrRouteHistoryEntry('/profile'); + snapshotAngularHmrRouteHistory(); + + const router = createRouterMock(); + const replay = new NativeScriptAngularHmrRouteReplay(router as any); + + router.emitNavigationCancel('/talk/(todayTab:today)'); + + expect(router.navigateByUrl).not.toHaveBeenCalled(); + expect(readAngularHmrPendingRouteHistory()).toEqual([]); + // When the initial navigation never settles successfully there is no + // restored route to protect; we close the window straight away. + expect(isAngularHmrRestoringRoute()).toBe(false); + + replay.ngOnDestroy(); + }); +}); diff --git a/packages/angular/src/lib/legacy/router/hmr-route-replay.ts b/packages/angular/src/lib/legacy/router/hmr-route-replay.ts new file mode 100644 index 00000000..0ec08b13 --- /dev/null +++ b/packages/angular/src/lib/legacy/router/hmr-route-replay.ts @@ -0,0 +1,197 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { NavigationCancel, NavigationEnd, NavigationError, Router } from '@angular/router'; +import { Subscription } from 'rxjs'; +import { filter, take } from 'rxjs/operators'; + +import { isAngularHmrEnabled } from '../../hmr-environment'; +import { NativeScriptDebug } from '../../trace'; +import { + clearAngularHmrPendingRouteHistory, + endAngularHmrRouteRestore, + isAngularHmrRestoringRoute, + readAngularHmrPendingForwardNavigations, +} from './hmr-route-state-core'; + +/** + * Grace period to keep `isAngularHmrRestoringRoute()` returning `true` + * after `replayForwardNavigations()` finishes its last `navigateByUrl`. + * + * Why a grace period exists: NativeScript native views (TabView, BottomNavigation, + * Frame, etc.) fire their `loaded` events asynchronously after the JS-side + * `NavigationEnd`. User-app code wired to those events typically guards a + * default navigation (e.g. "select first tab") with `isAngularHmrRestoringRoute()`. + * If we close the window the instant the JS replay finishes, the loaded + * event arrives a few hundred milliseconds later, the guard reports false, + * and the default navigation stomps the freshly-restored route. + * + * 1000ms covers all the cases observed on iOS device + simulator without + * leaving the window open long enough to interfere with genuine user + * navigation. The fallback timeout (`fallback-timeout`) below is a safety + * net for scenarios where this scheduled close never fires. + */ +const REPLAY_COMPLETED_GRACE_MS = 1000; + +/** + * Replays the back-stack snapshot captured by `NativeScriptAngularHmrRouteTracker` + * during HMR. The router's initial navigation already lands on the bottom of + * the stack (`stack[0]`); this service walks `stack[1..n]` so the user keeps + * back navigation across HMR cycles. + * + * The replay is single-shot per bootstrap. Any failure (cancelled navigation, + * unrouteable URL) aborts the rest of the replay so we don't fight the router + * — the user keeps whichever subset of the stack we successfully re-pushed. + */ +@Injectable() +export class NativeScriptAngularHmrRouteReplay implements OnDestroy { + private subscription?: Subscription; + private windowFallbackTimeout?: ReturnType; + private pendingCloseTimeout?: ReturnType; + + constructor(private readonly router: Router) { + if (!isAngularHmrEnabled()) { + return; + } + + const forwardNavigations = readAngularHmrPendingForwardNavigations(); + + // The restoring window is opened by `readAngularHmrPendingStartPath()` + // when `START_PATH` resolves to a deep route. If that path resolved + // to nothing AND we have no forward navigations, there is nothing + // to suppress and we must close the window if it was somehow left + // open. Otherwise we keep it open until replay finishes. + const restoringWindowOpen = isAngularHmrRestoringRoute(); + + if (forwardNavigations.length === 0) { + // Nothing to replay; clear the pending slot so a future navigation that + // ends in the bootstrap window doesn't carry the snapshot forward. + clearAngularHmrPendingRouteHistory(); + + if (restoringWindowOpen) { + // Single-URL restore (no back-stack to walk): keep the window + // open until the initial navigation completes so user-app + // default navigations don't fire before the framework's + // restored URL settles. We then schedule the close with the + // same grace period as the multi-URL replay path so async + // native `loaded` handlers still see the flag. + this.subscription = this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd || event instanceof NavigationCancel || event instanceof NavigationError), + take(1), + ) + .subscribe(() => this.scheduleRestoringWindowClose('initial-navigation-settled')); + // Belt-and-braces: bootstrap can race with router init in + // unusual cases. Close the window after a short timeout so we + // never leave it stuck open and silently breaking default + // navigations forever. + this.windowFallbackTimeout = setTimeout(() => this.closeRestoringWindow('fallback-timeout'), 5000); + } + + return; + } + + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`HMR back-stack replay queued: ${forwardNavigations.length} forward navigation(s)`); + } + + this.subscription = this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd || event instanceof NavigationCancel || event instanceof NavigationError), + take(1), + ) + .subscribe((event) => { + if (event instanceof NavigationEnd) { + void this.replayForwardNavigations(forwardNavigations); + } else { + // Initial navigation never landed; replay would compound the problem. + clearAngularHmrPendingRouteHistory(); + this.closeRestoringWindow('initial-navigation-failed'); + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog('HMR back-stack replay skipped: initial navigation did not complete'); + } + } + }); + + // Same belt-and-braces fallback as the single-URL path above. + this.windowFallbackTimeout = setTimeout(() => this.closeRestoringWindow('fallback-timeout'), 10000); + } + + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + if (this.windowFallbackTimeout !== undefined) { + clearTimeout(this.windowFallbackTimeout); + this.windowFallbackTimeout = undefined; + } + if (this.pendingCloseTimeout !== undefined) { + clearTimeout(this.pendingCloseTimeout); + this.pendingCloseTimeout = undefined; + } + // Defensive: never leave the restoring window open across module + // destruction. A subsequent reboot would otherwise see it set and + // suppress the next default navigation indefinitely. + this.closeRestoringWindow('replay-service-destroyed'); + } + + private closeRestoringWindow(reason: string): void { + if (this.pendingCloseTimeout !== undefined) { + clearTimeout(this.pendingCloseTimeout); + this.pendingCloseTimeout = undefined; + } + if (!isAngularHmrRestoringRoute()) { + return; + } + endAngularHmrRouteRestore(); + if (this.windowFallbackTimeout !== undefined) { + clearTimeout(this.windowFallbackTimeout); + this.windowFallbackTimeout = undefined; + } + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`HMR restoring-route window closed (${reason})`); + } + } + + /** + * Schedule the restoring window to close after a small grace period + * so that asynchronous user-app handlers (e.g. NativeScript native + * `loaded` events on TabView / BottomNavigation / Frame) still observe + * `isAngularHmrRestoringRoute() === true` and skip default navigations + * that would otherwise stomp the freshly-restored route. + * + * The grace period is bounded by the existing `fallback-timeout` so + * we never leave the flag set indefinitely even if `setTimeout` is + * blocked by a misbehaving consumer. + */ + private scheduleRestoringWindowClose(reason: string): void { + if (!isAngularHmrRestoringRoute()) { + return; + } + if (this.pendingCloseTimeout !== undefined) { + clearTimeout(this.pendingCloseTimeout); + } + this.pendingCloseTimeout = setTimeout(() => { + this.pendingCloseTimeout = undefined; + this.closeRestoringWindow(reason); + }, REPLAY_COMPLETED_GRACE_MS); + } + + private async replayForwardNavigations(urls: string[]): Promise { + let aborted = false; + try { + for (const url of urls) { + const succeeded = await this.router.navigateByUrl(url).catch(() => false); + if (!succeeded) { + aborted = true; + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`HMR back-stack replay aborted at ${url}`); + } + return; + } + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`HMR back-stack replay navigated to ${url}`); + } + } + } finally { + clearAngularHmrPendingRouteHistory(); + this.scheduleRestoringWindowClose(aborted ? 'replay-aborted' : 'replay-completed'); + } + } +} diff --git a/packages/angular/src/lib/legacy/router/hmr-route-state-core.ts b/packages/angular/src/lib/legacy/router/hmr-route-state-core.ts index 06cbec17..42bda6d8 100644 --- a/packages/angular/src/lib/legacy/router/hmr-route-state-core.ts +++ b/packages/angular/src/lib/legacy/router/hmr-route-state-core.ts @@ -7,11 +7,40 @@ type AngularHmrRouteState = { const CURRENT_ROUTE_KEY = '__NS_ANGULAR_HMR_CURRENT_ROUTE__'; const PENDING_START_PATH_KEY = '__NS_ANGULAR_HMR_PENDING_START_PATH__'; const CAPTURE_ROUTE_KEY = '__NS_CAPTURE_ANGULAR_HMR_ROUTE__'; +// Stack of normalized URLs that mirrors Angular Router's back-stack while the +// app is running, and is snapshotted into `PENDING_HISTORY_KEY` when an HMR +// reboot is about to fire. After the new module bootstraps, the router replay +// hook walks the stack to rebuild the back-stack so users keep their back +// navigation across HMR cycles. +const HISTORY_KEY = '__NS_ANGULAR_HMR_ROUTE_HISTORY__'; +const PENDING_HISTORY_KEY = '__NS_ANGULAR_HMR_PENDING_HISTORY__'; +// Window flag set while the new bootstrap is mid-replay of a captured route +// stack. User-app code can consult this to skip default navigations that +// would otherwise stomp the route the framework is restoring (e.g. a +// bottom-nav component that defaults to its first tab on init when no +// signal-backed selection exists). +const RESTORING_KEY = '__NS_ANGULAR_HMR_RESTORING_ROUTE__'; +const RESTORING_TARGET_KEY = '__NS_ANGULAR_HMR_RESTORING_ROUTE_TARGET__'; function getGlobalState(): any { return globalThis as any; } +function readHistoryArray(key: string): string[] { + const g = getGlobalState(); + const raw = g[key]; + return Array.isArray(raw) ? (raw.filter((entry) => typeof entry === 'string') as string[]) : []; +} + +function writeHistoryArray(key: string, history: string[]): void { + const g = getGlobalState(); + if (history.length > 0) { + g[key] = history.slice(); + } else { + delete g[key]; + } +} + export function normalizeAngularHmrRouteUrl(value: unknown): string | null { if (typeof value !== 'string') { return null; @@ -65,8 +94,32 @@ export function captureAngularHmrPendingStartPath(value: unknown, source = 'hmr- } export function readAngularHmrPendingStartPath(): string { + // When a back-stack snapshot exists we boot to the bottom of the stack and + // let `replayAngularHmrPendingForwardNavigations` walk the rest. Otherwise + // fall back to the legacy single-URL slot so projects without history + // tracking still land on the page they were viewing. + const pendingHistory = readHistoryArray(PENDING_HISTORY_KEY); + if (pendingHistory.length > 0) { + // Open the restoring-route window so user-app default navigations + // can step out of the framework's way until replay completes. The + // forward-navigation walk in `NativeScriptAngularHmrRouteReplay` + // closes the window after the final URL lands or fails. We pass + // the deepest captured URL so consumers can compare against the + // active router URL if they want fine-grained suppression. + beginAngularHmrRouteRestore(pendingHistory[pendingHistory.length - 1]); + return pendingHistory[0]; + } + const g = getGlobalState(); - return normalizeAngularHmrRouteUrl(g[PENDING_START_PATH_KEY]?.url ?? g[PENDING_START_PATH_KEY]) || ''; + const fallback = normalizeAngularHmrRouteUrl(g[PENDING_START_PATH_KEY]?.url ?? g[PENDING_START_PATH_KEY]) || ''; + if (fallback) { + // Single-URL fallback path: user-app code should still suppress + // default navigations briefly — the new bootstrap is about to + // navigate to `fallback`, so a default tab init that fires first + // would still stomp it. + beginAngularHmrRouteRestore(fallback); + } + return fallback; } export function invokeAngularHmrRouteCapture(): string | null { @@ -92,4 +145,209 @@ export function installAngularHmrRouteCaptureHook(capture: () => string | null): delete g[CAPTURE_ROUTE_KEY]; } }; +} + +// ---- back-stack history primitives ------------------------------------------ + +/** + * Push a URL onto the live back-stack mirror. The mirror is collapsed when the + * incoming URL equals the top — Angular fires multiple `NavigationEnd` events + * for the same URL during certain `replaceUrl` scenarios and we don't want to + * inflate the stack. + */ +export function pushAngularHmrRouteHistoryEntry(value: unknown): string | null { + const url = normalizeAngularHmrRouteUrl(value); + if (!url) { + return null; + } + + const history = readHistoryArray(HISTORY_KEY); + if (history.length > 0 && history[history.length - 1] === url) { + return url; + } + + history.push(url); + writeHistoryArray(HISTORY_KEY, history); + return url; +} + +/** + * Pop the top of the live back-stack mirror. Used when Angular reports a + * `popstate`-triggered navigation so the mirror tracks back navigations. + */ +export function popAngularHmrRouteHistoryEntry(): string | null { + const history = readHistoryArray(HISTORY_KEY); + if (history.length === 0) { + return null; + } + const popped = history.pop() ?? null; + writeHistoryArray(HISTORY_KEY, history); + return popped; +} + +/** + * Replace the top of the live back-stack mirror. Used when Angular reports a + * `NavigationEnd` with `replaceUrl=true`, e.g. canonical-redirect cycles. + */ +export function replaceAngularHmrRouteHistoryTop(value: unknown): string | null { + const url = normalizeAngularHmrRouteUrl(value); + if (!url) { + return null; + } + + const history = readHistoryArray(HISTORY_KEY); + if (history.length === 0) { + history.push(url); + } else { + history[history.length - 1] = url; + } + writeHistoryArray(HISTORY_KEY, history); + return url; +} + +/** + * Read a defensive copy of the live back-stack mirror. + */ +export function readAngularHmrRouteHistory(): string[] { + return readHistoryArray(HISTORY_KEY); +} + +/** + * Reset the live back-stack mirror. Used by tests and on bootstrap when the + * router cannot replay the captured stack so we don't carry stale entries + * forward. + */ +export function clearAngularHmrRouteHistory(): void { + const g = getGlobalState(); + delete g[HISTORY_KEY]; +} + +/** + * Snapshot the live back-stack mirror under the pending-history slot so the + * next bootstrap can read it. Called from the HMR capture hook. + * + * The live mirror is cleared after the copy so the freshly bootstrapped app + * starts from an empty back-stack. The replay walks the captured snapshot + * via `NativeScriptAngularHmrRouteReplay` which fires `NavigationEnd` for + * every URL it touches; the new tracker subscribes to those events and + * naturally rebuilds the live mirror to match the snapshot. Without this + * reset the live mirror would accumulate every URL the replay re-pushes + * across HMR cycles, growing without bound and turning subsequent snapshots + * into runaway forward-navigation walks (each replayed forward nav from + * `/profile` back into `/talk` creates a fresh `TalkComponent` because + * forward navigation never reuses the cache, so the leak shows up as + * duplicated `Norrix is not enabled` / `BottomNavComponent Router Event:` + * lines that double on every save). + * + * Returns the snapshot for diagnostics. Defensive: an empty live mirror + * leaves the pending slot untouched so a single-page snapshot still works. + */ +export function snapshotAngularHmrRouteHistory(): string[] { + const live = readHistoryArray(HISTORY_KEY); + if (live.length === 0) { + return []; + } + writeHistoryArray(PENDING_HISTORY_KEY, live); + // Clear the live mirror so the next bootstrap starts from a clean slate. + // The replay will repopulate it via the new tracker's NavigationEnd + // subscription as it walks the captured stack. + writeHistoryArray(HISTORY_KEY, []); + return live.slice(); +} + +/** + * Read the snapshotted back-stack pending replay on the new bootstrap. + */ +export function readAngularHmrPendingRouteHistory(): string[] { + return readHistoryArray(PENDING_HISTORY_KEY); +} + +/** + * Read URLs to navigate forward through after the initial navigation finishes. + * The first entry of the stack is the `START_PATH` consumed by the router; the + * rest are forward navigations to push onto the new back-stack. + */ +export function readAngularHmrPendingForwardNavigations(): string[] { + const pending = readHistoryArray(PENDING_HISTORY_KEY); + if (pending.length <= 1) { + return []; + } + return pending.slice(1); +} + +/** + * Clear the pending snapshot. The router replay calls this once it finishes + * walking the stack so subsequent reboots start fresh. + */ +export function clearAngularHmrPendingRouteHistory(): void { + const g = getGlobalState(); + delete g[PENDING_HISTORY_KEY]; +} + +// ---- restoring-route window flag -------------------------------------------- + +/** + * True while the Angular HMR layer is restoring a captured route stack + * onto the freshly-bootstrapped router. The window opens just before + * `START_PATH` resolves to a deep URL and closes once the router has + * walked the entire forward navigation list (or aborted it). + * + * User-app code that runs default navigations on component init (e.g. a + * bottom-nav defaulting to its first tab) can consult this flag to skip + * its default navigation so the framework's restored route survives: + * + * ```ts + * if (isAngularHmrRestoringRoute()) { + * return; // framework is restoring a deeper route — leave it alone. + * } + * defaultTabNavigation(); + * ``` + * + * Returns `false` outside of HMR or after the replay window has closed. + * Production builds always see `false` because the framework never + * opens the window there. + */ +export function isAngularHmrRestoringRoute(): boolean { + const g = getGlobalState(); + return g[RESTORING_KEY] === true; +} + +/** + * The target route the framework is currently restoring, or `null` when + * no replay is in progress. Useful when the consumer wants to compare + * against the current router URL. + */ +export function getAngularHmrRestoringRoute(): string | null { + const g = getGlobalState(); + const value = g[RESTORING_TARGET_KEY]; + return typeof value === 'string' && value ? value : null; +} + +/** + * Open the restoring-route window. Called by the framework when an HMR + * bootstrap is about to navigate to a captured deep route — never call + * this from user code. + * + * `targetUrl` is what the framework intends to land on; the value can + * be read back via {@link getAngularHmrRestoringRoute}. + */ +export function beginAngularHmrRouteRestore(targetUrl: string | null): void { + const g = getGlobalState(); + g[RESTORING_KEY] = true; + if (targetUrl) { + g[RESTORING_TARGET_KEY] = targetUrl; + } else { + delete g[RESTORING_TARGET_KEY]; + } +} + +/** + * Close the restoring-route window. Called by the framework when the + * replay finishes (NavigationEnd reached, replay aborted, or no + * pending stack existed in the first place). + */ +export function endAngularHmrRouteRestore(): void { + const g = getGlobalState(); + delete g[RESTORING_KEY]; + delete g[RESTORING_TARGET_KEY]; } \ No newline at end of file diff --git a/packages/angular/src/lib/legacy/router/hmr-route-state-tracker.spec.ts b/packages/angular/src/lib/legacy/router/hmr-route-state-tracker.spec.ts new file mode 100644 index 00000000..868020c6 --- /dev/null +++ b/packages/angular/src/lib/legacy/router/hmr-route-state-tracker.spec.ts @@ -0,0 +1,197 @@ +jest.mock('@angular/core', () => ({ + Injectable: () => (target: unknown) => target, +})); + +class MockNavigationStart { + constructor( + public id: number, + public url: string, + public navigationTrigger?: 'imperative' | 'popstate' | 'hashchange', + public restoredState?: { navigationId: number } | null, + ) {} +} +class MockNavigationEnd { + constructor( + public id: number, + public url: string, + public urlAfterRedirects: string, + ) {} +} + +jest.mock('@angular/router', () => ({ + NavigationStart: MockNavigationStart, + NavigationEnd: MockNavigationEnd, + Router: class {}, +})); + +jest.mock('../../hmr-environment', () => ({ + isAngularHmrEnabled: () => true, +})); + +import { Subject } from 'rxjs'; + +import { + clearAngularHmrPendingRouteHistory, + clearAngularHmrRouteHistory, + endAngularHmrRouteRestore, + readAngularHmrPendingRouteHistory, + readAngularHmrRouteHistory, +} from './hmr-route-state-core'; +import { NativeScriptAngularHmrRouteTracker } from './hmr-route-state'; + +interface RouterEvent { + url?: string; +} + +interface RouterMock { + events: Subject; + url: string; + emitNavigationEnd(url: string): void; + emitNavigationStart( + url: string, + options?: { + trigger?: 'imperative' | 'popstate' | 'hashchange'; + restoredState?: { navigationId: number } | null; + }, + ): void; +} + +function createRouterMock(initialUrl: string): RouterMock { + const events = new Subject(); + return { + events, + url: initialUrl, + emitNavigationStart(url, options) { + events.next( + new MockNavigationStart( + 1, + url, + options?.trigger ?? 'imperative', + options?.restoredState ?? null, + ) as unknown as RouterEvent, + ); + }, + emitNavigationEnd(url) { + this.url = url; + events.next(new MockNavigationEnd(1, url, url) as unknown as RouterEvent); + }, + }; +} + +describe('NativeScriptAngularHmrRouteTracker', () => { + beforeEach(() => { + clearAngularHmrRouteHistory(); + clearAngularHmrPendingRouteHistory(); + endAngularHmrRouteRestore(); + }); + + afterEach(() => { + clearAngularHmrRouteHistory(); + clearAngularHmrPendingRouteHistory(); + endAngularHmrRouteRestore(); + }); + + describe('bootstrap seed', () => { + it('does not seed the live mirror when the router is still at the root URL', () => { + const router = createRouterMock('/'); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const tracker = new NativeScriptAngularHmrRouteTracker(router as never); + + // The router has not run its initial navigation at ENVIRONMENT_INITIALIZER + // time so its url is "/". Pushing that here would put a noise entry at + // the bottom of the next snapshot — which becomes the next bootstrap's + // START_PATH and triggers a redirect → extra NavigationEnd → re-entry + // into the replay path. Skipping the seed avoids that whole loop; the + // first real NavigationEnd below seeds the mirror with the actual URL. + expect(readAngularHmrRouteHistory()).toEqual([]); + + router.emitNavigationStart('/talk/(todayTab:today)'); + router.emitNavigationEnd('/talk/(todayTab:today)'); + + expect(readAngularHmrRouteHistory()).toEqual(['/talk/(todayTab:today)']); + }); + + it('does not seed the live mirror when the router url is empty', () => { + const router = createRouterMock(''); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const tracker = new NativeScriptAngularHmrRouteTracker(router as never); + + expect(readAngularHmrRouteHistory()).toEqual([]); + }); + + it('still seeds the live mirror when the bootstrap router already sits at a real route', () => { + // Some tests / boot paths instantiate the tracker after the router has + // already settled on the initial route (e.g. with router state restored + // from disk). In that case the url is already meaningful and dropping + // it would lose the only entry we have until the next NavigationEnd. + const router = createRouterMock('/talk/(todayTab:today)'); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const tracker = new NativeScriptAngularHmrRouteTracker(router as never); + + expect(readAngularHmrRouteHistory()).toEqual(['/talk/(todayTab:today)']); + }); + }); + + describe('NavigationEnd integration', () => { + it('rebuilds the live mirror from a sequence of forward navigations', () => { + const router = createRouterMock('/'); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const tracker = new NativeScriptAngularHmrRouteTracker(router as never); + + router.emitNavigationStart('/talk/(todayTab:today)'); + router.emitNavigationEnd('/talk/(todayTab:today)'); + router.emitNavigationStart('/profile'); + router.emitNavigationEnd('/profile'); + + expect(readAngularHmrRouteHistory()).toEqual(['/talk/(todayTab:today)', '/profile']); + }); + + it('pops the live mirror on popstate-triggered NavigationEnd events', () => { + const router = createRouterMock('/'); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const tracker = new NativeScriptAngularHmrRouteTracker(router as never); + + router.emitNavigationStart('/talk/(todayTab:today)'); + router.emitNavigationEnd('/talk/(todayTab:today)'); + router.emitNavigationStart('/profile'); + router.emitNavigationEnd('/profile'); + router.emitNavigationStart('/talk/(todayTab:today)', { + trigger: 'popstate', + restoredState: { navigationId: 1 }, + }); + router.emitNavigationEnd('/talk/(todayTab:today)'); + + expect(readAngularHmrRouteHistory()).toEqual(['/talk/(todayTab:today)']); + }); + }); + + describe('clear-after-snapshot integration', () => { + it('a snapshot during HMR reboot leaves the live mirror empty for the next bootstrap to rebuild', () => { + // Cycle 1: real boot, walk forward to /profile. + const cycle1 = createRouterMock('/'); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const tracker1 = new NativeScriptAngularHmrRouteTracker(cycle1 as never); + cycle1.emitNavigationStart('/talk/(todayTab:today)'); + cycle1.emitNavigationEnd('/talk/(todayTab:today)'); + cycle1.emitNavigationStart('/profile'); + cycle1.emitNavigationEnd('/profile'); + + // HMR capture hook runs against the first tracker. + const captureHook = (globalThis as { __NS_CAPTURE_ANGULAR_HMR_ROUTE__?: () => string | null }) + .__NS_CAPTURE_ANGULAR_HMR_ROUTE__; + expect(captureHook).toBeDefined(); + captureHook?.(); + + expect(readAngularHmrPendingRouteHistory()).toEqual(['/talk/(todayTab:today)', '/profile']); + // Snapshot has cleared the live mirror so the cycle-2 tracker starts + // with a clean slate. Without the clear, the next cycle's snapshot + // would carry forward all prior entries and grow without bound. + expect(readAngularHmrRouteHistory()).toEqual([]); + }); + }); +}); diff --git a/packages/angular/src/lib/legacy/router/hmr-route-state.spec.ts b/packages/angular/src/lib/legacy/router/hmr-route-state.spec.ts index e4b4d753..55f9815b 100644 --- a/packages/angular/src/lib/legacy/router/hmr-route-state.spec.ts +++ b/packages/angular/src/lib/legacy/router/hmr-route-state.spec.ts @@ -1,9 +1,22 @@ import { + beginAngularHmrRouteRestore, captureAngularHmrPendingStartPath, + clearAngularHmrPendingRouteHistory, + clearAngularHmrRouteHistory, + endAngularHmrRouteRestore, + getAngularHmrRestoringRoute, invokeAngularHmrRouteCapture, installAngularHmrRouteCaptureHook, + isAngularHmrRestoringRoute, normalizeAngularHmrRouteUrl, + popAngularHmrRouteHistoryEntry, + pushAngularHmrRouteHistoryEntry, + readAngularHmrPendingForwardNavigations, + readAngularHmrPendingRouteHistory, readAngularHmrPendingStartPath, + readAngularHmrRouteHistory, + replaceAngularHmrRouteHistoryTop, + snapshotAngularHmrRouteHistory, writeAngularHmrRouteState, } from './hmr-route-state-core'; @@ -14,6 +27,9 @@ describe('Angular HMR route state', () => { delete g.__NS_ANGULAR_HMR_CURRENT_ROUTE__; delete g.__NS_ANGULAR_HMR_PENDING_START_PATH__; delete g.__NS_CAPTURE_ANGULAR_HMR_ROUTE__; + clearAngularHmrRouteHistory(); + clearAngularHmrPendingRouteHistory(); + endAngularHmrRouteRestore(); }); it('normalizes route-like values to app paths', () => { @@ -52,4 +68,180 @@ describe('Angular HMR route state', () => { expect(invokeAngularHmrRouteCapture()).toBe('/profile?tab=goals'); expect(readAngularHmrPendingStartPath()).toBe('/profile?tab=goals'); }); + + describe('back-stack history mirror', () => { + it('pushes URLs onto the live mirror and reads them back in order', () => { + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + pushAngularHmrRouteHistoryEntry('/profile'); + + expect(readAngularHmrRouteHistory()).toEqual(['/talk/(todayTab:today)', '/profile']); + }); + + it('collapses repeated pushes of the same top entry so canonical redirects do not inflate the stack', () => { + pushAngularHmrRouteHistoryEntry('/profile'); + pushAngularHmrRouteHistoryEntry('/profile'); + pushAngularHmrRouteHistoryEntry('/profile'); + + expect(readAngularHmrRouteHistory()).toEqual(['/profile']); + }); + + it('pops the top entry on back-style navigations', () => { + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + pushAngularHmrRouteHistoryEntry('/profile'); + + expect(popAngularHmrRouteHistoryEntry()).toBe('/profile'); + expect(readAngularHmrRouteHistory()).toEqual(['/talk/(todayTab:today)']); + }); + + it('replaces the top entry when NavigationEnd reports a replaceUrl navigation', () => { + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + pushAngularHmrRouteHistoryEntry('/profile'); + replaceAngularHmrRouteHistoryTop('/profile?tab=goals'); + + expect(readAngularHmrRouteHistory()).toEqual(['/talk/(todayTab:today)', '/profile?tab=goals']); + }); + + it('snapshots the live mirror into the pending slot for the next bootstrap', () => { + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + pushAngularHmrRouteHistoryEntry('/profile'); + + expect(snapshotAngularHmrRouteHistory()).toEqual(['/talk/(todayTab:today)', '/profile']); + expect(readAngularHmrPendingRouteHistory()).toEqual(['/talk/(todayTab:today)', '/profile']); + }); + + it('clears the live mirror after snapshotting so the next bootstrap starts fresh', () => { + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + pushAngularHmrRouteHistoryEntry('/profile'); + + snapshotAngularHmrRouteHistory(); + + // The live mirror is reset so the new bootstrap's tracker rebuilds it + // from the replay's NavigationEnd events; without this the live mirror + // would accumulate across HMR cycles and grow snapshots without bound. + expect(readAngularHmrRouteHistory()).toEqual([]); + // Pending snapshot is preserved for the new bootstrap to consume. + expect(readAngularHmrPendingRouteHistory()).toEqual(['/talk/(todayTab:today)', '/profile']); + }); + + it('does not let pushes after snapshot reach the pending slot', () => { + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + pushAngularHmrRouteHistoryEntry('/profile'); + snapshotAngularHmrRouteHistory(); + + // Simulate the new bootstrap's tracker subscribing to NavigationEnd + // events and pushing as the replay walks forward. + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + pushAngularHmrRouteHistoryEntry('/profile'); + + expect(readAngularHmrRouteHistory()).toEqual(['/talk/(todayTab:today)', '/profile']); + // Pending is still the original snapshot; the new tracker's pushes + // do not retroactively contaminate it. + expect(readAngularHmrPendingRouteHistory()).toEqual(['/talk/(todayTab:today)', '/profile']); + }); + + it('keeps the live mirror untouched when the snapshot is a no-op', () => { + // Empty live mirror: snapshot returns [] and must not clobber any + // earlier pending snapshot the previous HMR cycle wrote. + pushAngularHmrRouteHistoryEntry('/profile'); + snapshotAngularHmrRouteHistory(); + // After consuming a snapshot, mock the boundary where the previous + // pending snapshot is still recorded but the live mirror is empty. + expect(snapshotAngularHmrRouteHistory()).toEqual([]); + expect(readAngularHmrPendingRouteHistory()).toEqual(['/profile']); + expect(readAngularHmrRouteHistory()).toEqual([]); + }); + + it('exposes everything but the bottom of the stack as forward navigations', () => { + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + pushAngularHmrRouteHistoryEntry('/profile'); + pushAngularHmrRouteHistoryEntry('/profile/edit'); + snapshotAngularHmrRouteHistory(); + + expect(readAngularHmrPendingForwardNavigations()).toEqual(['/profile', '/profile/edit']); + }); + + it('returns an empty forward list when the snapshot has only the bottom of the stack', () => { + pushAngularHmrRouteHistoryEntry('/profile'); + snapshotAngularHmrRouteHistory(); + + expect(readAngularHmrPendingForwardNavigations()).toEqual([]); + }); + + it('uses the bottom of the snapshot as the pending start path so the router boots there first', () => { + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + pushAngularHmrRouteHistoryEntry('/profile'); + snapshotAngularHmrRouteHistory(); + + expect(readAngularHmrPendingStartPath()).toBe('/talk/(todayTab:today)'); + }); + + it('falls back to the legacy single-URL slot when no snapshot is present', () => { + captureAngularHmrPendingStartPath('/profile?tab=goals'); + + expect(readAngularHmrPendingStartPath()).toBe('/profile?tab=goals'); + expect(readAngularHmrPendingForwardNavigations()).toEqual([]); + }); + + it('clears the pending snapshot once the replay has finished', () => { + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + pushAngularHmrRouteHistoryEntry('/profile'); + snapshotAngularHmrRouteHistory(); + clearAngularHmrPendingRouteHistory(); + + expect(readAngularHmrPendingRouteHistory()).toEqual([]); + expect(readAngularHmrPendingForwardNavigations()).toEqual([]); + }); + }); + + describe('route restoration window', () => { + it('reports no restoration in progress by default', () => { + expect(isAngularHmrRestoringRoute()).toBe(false); + expect(getAngularHmrRestoringRoute()).toBeNull(); + }); + + it('opens the window for the requested target URL and closes it on demand', () => { + beginAngularHmrRouteRestore('/profile?tab=goals'); + + expect(isAngularHmrRestoringRoute()).toBe(true); + expect(getAngularHmrRestoringRoute()).toBe('/profile?tab=goals'); + + endAngularHmrRouteRestore(); + + expect(isAngularHmrRestoringRoute()).toBe(false); + expect(getAngularHmrRestoringRoute()).toBeNull(); + }); + + it('opens the window when the pending route history snapshot resolves a deep route', () => { + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + pushAngularHmrRouteHistoryEntry('/profile'); + snapshotAngularHmrRouteHistory(); + + expect(readAngularHmrPendingStartPath()).toBe('/talk/(todayTab:today)'); + expect(isAngularHmrRestoringRoute()).toBe(true); + // The window is opened with the deepest captured URL so user-app + // code can decide what to do based on where the framework is + // ultimately heading, not just the bottom of the stack. + expect(getAngularHmrRestoringRoute()).toBe('/profile'); + }); + + it('opens the window when only the legacy single-URL fallback is available', () => { + captureAngularHmrPendingStartPath('/profile?tab=goals'); + + expect(readAngularHmrPendingStartPath()).toBe('/profile?tab=goals'); + expect(isAngularHmrRestoringRoute()).toBe(true); + expect(getAngularHmrRestoringRoute()).toBe('/profile?tab=goals'); + }); + + it('does not open the window when there is no pending HMR start path', () => { + expect(readAngularHmrPendingStartPath()).toBe(''); + expect(isAngularHmrRestoringRoute()).toBe(false); + }); + + it('coerces non-string targets and discards empty values', () => { + beginAngularHmrRouteRestore(undefined as unknown as string); + + expect(isAngularHmrRestoringRoute()).toBe(true); + expect(getAngularHmrRestoringRoute()).toBeNull(); + }); + }); }); \ No newline at end of file diff --git a/packages/angular/src/lib/legacy/router/hmr-route-state.ts b/packages/angular/src/lib/legacy/router/hmr-route-state.ts index 7a5eeb99..7b955008 100644 --- a/packages/angular/src/lib/legacy/router/hmr-route-state.ts +++ b/packages/angular/src/lib/legacy/router/hmr-route-state.ts @@ -1,33 +1,81 @@ import { Injectable, OnDestroy } from '@angular/core'; -import { NavigationEnd, Router } from '@angular/router'; +import { NavigationEnd, NavigationStart, Router } from '@angular/router'; import { Subscription } from 'rxjs'; +import { isAngularHmrEnabled } from '../../hmr-environment'; import { installAngularHmrRouteCaptureHook, + popAngularHmrRouteHistoryEntry, + pushAngularHmrRouteHistoryEntry, readAngularHmrPendingStartPath, + replaceAngularHmrRouteHistoryTop, + snapshotAngularHmrRouteHistory, writeAngularHmrRouteState, } from './hmr-route-state-core'; -export { captureAngularHmrPendingStartPath, invokeAngularHmrRouteCapture, normalizeAngularHmrRouteUrl } from './hmr-route-state-core'; +export { + beginAngularHmrRouteRestore, + captureAngularHmrPendingStartPath, + clearAngularHmrPendingRouteHistory, + clearAngularHmrRouteHistory, + endAngularHmrRouteRestore, + getAngularHmrRestoringRoute, + invokeAngularHmrRouteCapture, + isAngularHmrRestoringRoute, + normalizeAngularHmrRouteUrl, + popAngularHmrRouteHistoryEntry, + pushAngularHmrRouteHistoryEntry, + readAngularHmrPendingForwardNavigations, + readAngularHmrPendingRouteHistory, + readAngularHmrRouteHistory, + replaceAngularHmrRouteHistoryTop, + snapshotAngularHmrRouteHistory, +} from './hmr-route-state-core'; export { readAngularHmrPendingStartPath } from './hmr-route-state-core'; @Injectable() export class NativeScriptAngularHmrRouteTracker implements OnDestroy { private subscription?: Subscription; private disposeCaptureHook?: () => void; + // Tracks whether the current `NavigationStart..NavigationEnd` pair was kicked + // off by a popstate (frame.goBack / NSLocationStrategy.back) so that on + // `NavigationEnd` we can pop our mirror instead of pushing a duplicate entry. + private currentNavigationIsPopstate = false; + private currentNavigationReplaceUrl = false; constructor(private readonly router: Router) { - if (!this.isHmrEnabled()) { + if (!isAngularHmrEnabled()) { return; } this.disposeCaptureHook = this.installCaptureHook(); this.captureCurrentRoute('bootstrap'); this.subscription = this.router.events.subscribe((event) => { + if (event instanceof NavigationStart) { + this.currentNavigationIsPopstate = event.navigationTrigger === 'popstate'; + this.currentNavigationReplaceUrl = !!event.restoredState; + return; + } + if (event instanceof NavigationEnd) { - writeAngularHmrRouteState(event.urlAfterRedirects || event.url, { + const url = event.urlAfterRedirects || event.url; + writeAngularHmrRouteState(url, { source: 'navigation-end', }); + + if (this.currentNavigationIsPopstate) { + // The user (or NSLocationStrategy.back()) walked the back-stack down + // by one page; mirror that by dropping the top of our snapshot so a + // subsequent HMR reboot doesn't carry the popped page back into view. + popAngularHmrRouteHistoryEntry(); + } else if (this.currentNavigationReplaceUrl) { + replaceAngularHmrRouteHistoryTop(url); + } else { + pushAngularHmrRouteHistoryEntry(url); + } + + this.currentNavigationIsPopstate = false; + this.currentNavigationReplaceUrl = false; } }); } @@ -38,6 +86,32 @@ export class NativeScriptAngularHmrRouteTracker implements OnDestroy { } private captureCurrentRoute(source: string): string | null { + if (source === 'hmr-reboot') { + // Snapshot the live mirror first so the bootstrap can replay forward + // navigations to rebuild the back-stack. The pending single-URL slot + // remains useful as a fallback when the snapshot turns out to be empty + // (e.g. bootstrap-time HMR before the first NavigationEnd). + snapshotAngularHmrRouteHistory(); + } else if (source === 'bootstrap') { + // Seed the live mirror with the current URL so the very first HMR + // before any user navigation still has a stack of size one to snapshot. + // + // Skip empty / root URLs: at ENVIRONMENT_INITIALIZER time the router + // has not run its initial navigation yet so `router.url` is "/" (or + // an empty string). Pushing that here would seed the mirror with a + // noise entry that becomes the bottom of the next snapshot, which in + // turn becomes the next bootstrap's `START_PATH`. The router then + // boots to "/" → redirects to the real default route → fires an + // extra `NavigationEnd` that re-enters the replay path. The first + // genuine `NavigationEnd` arrives a moment later through the event + // subscription below and seeds the mirror with the real URL, so + // dropping the seed here is safe. + const seedUrl = this.router.url; + if (seedUrl && seedUrl !== '/') { + pushAngularHmrRouteHistoryEntry(seedUrl); + } + } + return writeAngularHmrRouteState(this.router.url, { pending: source === 'hmr-reboot', source, @@ -47,9 +121,4 @@ export class NativeScriptAngularHmrRouteTracker implements OnDestroy { private installCaptureHook(): () => void { return installAngularHmrRouteCaptureHook(() => this.captureCurrentRoute('hmr-reboot')); } - - private isHmrEnabled(): boolean { - const g = globalThis as any; - return !!g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__ || typeof g.__reboot_ng_modules__ === 'function'; - } } \ No newline at end of file diff --git a/packages/angular/src/lib/legacy/router/index.ts b/packages/angular/src/lib/legacy/router/index.ts index 52cf4562..8c3478b3 100644 --- a/packages/angular/src/lib/legacy/router/index.ts +++ b/packages/angular/src/lib/legacy/router/index.ts @@ -1,3 +1,9 @@ export { NSLocationStrategy } from './ns-location-strategy'; export { NSRouteReuseStrategy } from './ns-route-reuse-strategy'; export * from './router.module'; +// HMR helpers user-app code can consult to coordinate with the +// framework while it restores a captured route stack on hot reload. +// `isAngularHmrRestoringRoute()` returns `false` outside of HMR (and +// always in production), so call sites can leave it permanently in +// place without guarding. +export { getAngularHmrRestoringRoute, isAngularHmrRestoringRoute } from './hmr-route-state-core'; diff --git a/packages/angular/src/lib/legacy/router/router.module.ts b/packages/angular/src/lib/legacy/router/router.module.ts index 5894caf0..cdb42f94 100644 --- a/packages/angular/src/lib/legacy/router/router.module.ts +++ b/packages/angular/src/lib/legacy/router/router.module.ts @@ -35,6 +35,7 @@ import { NSEmptyOutletComponent } from './ns-empty-outlet.component'; import { NativeScriptCommonModule } from '../../nativescript-common.module'; import { START_PATH } from '../../tokens'; import { cloneRoutesForBootstrap } from './hmr-route-bootstrap-core'; +import { NativeScriptAngularHmrRouteReplay } from './hmr-route-replay'; import { NativeScriptAngularHmrRouteTracker, readAngularHmrPendingStartPath } from './hmr-route-state'; import { ComponentInputBindingOptions, INPUT_BINDER, RoutedComponentInputBinder } from './router-component-input-binder'; @@ -95,10 +96,11 @@ export class NativeScriptRouterModule { NSRouteReuseStrategy, { provide: RouteReuseStrategy, useExisting: NSRouteReuseStrategy }, NativeScriptAngularHmrRouteTracker, + NativeScriptAngularHmrRouteReplay, { provide: APP_BOOTSTRAP_LISTENER, multi: true, - deps: [NativeScriptAngularHmrRouteTracker], + deps: [NativeScriptAngularHmrRouteTracker, NativeScriptAngularHmrRouteReplay], useFactory: () => () => undefined, }, config?.bindToComponentInputs @@ -136,11 +138,13 @@ export function provideNativeScriptRouter(routes: Routes, ...features: RouterFea NSRouteReuseStrategy, { provide: RouteReuseStrategy, useExisting: NSRouteReuseStrategy }, NativeScriptAngularHmrRouteTracker, + NativeScriptAngularHmrRouteReplay, { provide: ENVIRONMENT_INITIALIZER, multi: true, useValue: () => { inject(NativeScriptAngularHmrRouteTracker); + inject(NativeScriptAngularHmrRouteReplay); }, }, // {provide: APP_BOOTSTRAP_LISTENER, multi: true, useFactory: getBootstrapListener}, From 17e26e0215d5c53104a1453d118b256752658996 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 26 Apr 2026 11:09:50 -0700 Subject: [PATCH 11/19] chore: cleanup logs --- packages/angular/src/lib/application.ts | 36 +++++++++++-------- .../src/lib/cdk/dialog/dialog-services.ts | 26 +++++++------- .../src/lib/hmr-class-registry.spec.ts | 11 ++++++ .../angular/src/lib/hmr-class-registry.ts | 21 ++++++----- .../src/lib/hmr-eager-services.spec.ts | 11 ++++++ .../angular/src/lib/hmr-eager-services.ts | 12 +++---- 6 files changed, 73 insertions(+), 44 deletions(-) diff --git a/packages/angular/src/lib/application.ts b/packages/angular/src/lib/application.ts index 4e787806..490f4328 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -161,7 +161,9 @@ function emitModuleBootstrapEvent( name: 'main' | 'loading', reason: NgModuleReason, ) { - console.info(`[ns-hmr-diag][application] emitModuleBootstrapEvent name=${name} reason=${reason}`); + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`emitModuleBootstrapEvent name=${name} reason=${reason}`); + } // Instantiate registered HMR-aware services *before* emitting so they // attach their subscriptions in the same JS task and are guaranteed to // observe the event being emitted. `postAngularBootstrap$` is also a @@ -179,7 +181,9 @@ function emitModuleBootstrapEvent( reference: ref, reason, }); - console.info(`[ns-hmr-diag][application] postAngularBootstrap$.next() emitted name=${name} reason=${reason}`); + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`postAngularBootstrap$.next() emitted name=${name} reason=${reason}`); + } } function destroyRef(ref: NgModuleRef | ApplicationRef, name: 'main' | 'loading', reason: NgModuleReason): void; @@ -187,7 +191,10 @@ function destroyRef(ref: PlatformRef, reason: NgModuleReason): void; function destroyRef(ref: PlatformRef | ApplicationRef | NgModuleRef, name?: string, reason?: string): void { if (ref) { const refKind = ref instanceof PlatformRef ? 'PlatformRef' : ref instanceof NgModuleRef ? 'NgModuleRef' : ref instanceof ApplicationRef ? 'ApplicationRef' : '(unknown)'; - console.info(`[ns-hmr-diag][application] destroyRef kind=${refKind} name=${name ?? '(none)'} reason=${reason ?? '(none)'}`); + const traceEnabled = NativeScriptDebug.isLogEnabled(); + if (traceEnabled) { + NativeScriptDebug.hmrLog(`destroyRef kind=${refKind} name=${name ?? '(none)'} reason=${reason ?? '(none)'}`); + } if (ref instanceof PlatformRef) { preAngularDisposal$.next({ moduleType: 'platform', @@ -203,7 +210,9 @@ function destroyRef(ref: PlatformRef | ApplicationRef | NgModuleRef, name? }); } ref.destroy(); - console.info(`[ns-hmr-diag][application] destroyRef DONE kind=${refKind} name=${name ?? '(none)'}`); + if (traceEnabled) { + NativeScriptDebug.hmrLog(`destroyRef DONE kind=${refKind} name=${name ?? '(none)'}`); + } } } @@ -741,36 +750,33 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { disposePlatform('hotreload'); }; global['__reboot_ng_modules__'] = (shouldDisposePlatform: boolean = false) => { - // Diagnostic: bump the global HMR cycle counter so all subsequent - // log lines (class registry, dialog services) can be cross- - // referenced to a specific reboot. + // Bump the global HMR cycle counter so subsequent diagnostic log + // lines (class registry, dialog services) can be cross-referenced + // to a specific reboot. Counter is always incremented; the trace + // category gates whether we surface it in the console. const cycleNum = _hmrDiagBumpCycle(); - console.info(`[ns-hmr-diag][application] __reboot_ng_modules__ called cycle=${cycleNum} shouldDisposePlatform=${shouldDisposePlatform} bootstrapId=${bootstrapId} hasMainModuleRef=${!!mainModuleRef}`); const traceEnabled = NativeScriptDebug.isLogEnabled(); if (traceEnabled) { NativeScriptDebug.hmrLog( - `__reboot_ng_modules__ called shouldDisposePlatform=${shouldDisposePlatform} bootstrapId=${bootstrapId} hasMainModuleRef=${!!mainModuleRef}`, + `__reboot_ng_modules__ called cycle=${cycleNum} shouldDisposePlatform=${shouldDisposePlatform} bootstrapId=${bootstrapId} hasMainModuleRef=${!!mainModuleRef}`, ); } try { global['__NS_CAPTURE_ANGULAR_HMR_ROUTE__']?.(); } catch {} disposeLastModules('hotreload'); - console.info(`[ns-hmr-diag][application] after disposeLastModules cycle=${cycleNum} bootstrapId=${bootstrapId}`); if (traceEnabled) { - NativeScriptDebug.hmrLog(`after disposeLastModules bootstrapId=${bootstrapId}`); + NativeScriptDebug.hmrLog(`after disposeLastModules cycle=${cycleNum} bootstrapId=${bootstrapId}`); } if (shouldDisposePlatform) { disposePlatform('hotreload'); } if (traceEnabled) { - NativeScriptDebug.hmrLog('calling bootstrapRoot'); + NativeScriptDebug.hmrLog(`calling bootstrapRoot cycle=${cycleNum}`); } - console.info(`[ns-hmr-diag][application] calling bootstrapRoot cycle=${cycleNum}`); bootstrapRoot('hotreload'); - console.info(`[ns-hmr-diag][application] bootstrapRoot returned cycle=${cycleNum} bootstrapId=${bootstrapId}`); if (traceEnabled) { - NativeScriptDebug.hmrLog(`bootstrapRoot returned bootstrapId=${bootstrapId}`); + NativeScriptDebug.hmrLog(`bootstrapRoot returned cycle=${cycleNum} bootstrapId=${bootstrapId}`); } }; diff --git a/packages/angular/src/lib/cdk/dialog/dialog-services.ts b/packages/angular/src/lib/cdk/dialog/dialog-services.ts index 0c1c1962..e1c5608b 100644 --- a/packages/angular/src/lib/cdk/dialog/dialog-services.ts +++ b/packages/angular/src/lib/cdk/dialog/dialog-services.ts @@ -42,31 +42,33 @@ import { NativeDialogRef } from './dialog-ref'; import { NativeModalRef } from './native-modal-ref'; /** - * Always-visible HMR diagnostic prefix. We use the same `[ns-hmr][angular]` - * tag the Vite Angular client uses for refresh/reboot lines so devs see - * dialog HMR events on the same console channel without flipping - * `Trace.isEnabled()` (which is off by default and gates - * `NativeScriptDebug.hmrLog`). The helper short-circuits in production - * because every caller is already gated on `isAngularHmrEnabled()`. + * Dialog HMR lifecycle log. */ function hmrDialogLog(message: string): void { if (!isAngularHmrEnabled()) { return; } - console.info(`[ns-hmr][angular][dialog] ${message}`); + if (!NativeScriptDebug.isLogEnabled()) { + return; + } + NativeScriptDebug.hmrLog(`[dialog] ${message}`); } /** - * Diagnostic helper. Distinct from `hmrDialogLog` so we can grep - * separately for "low-level wiring" facts (module-realm count, - * NativeDialog instance count, registry hits/misses) vs. high-level - * lifecycle messages. + * Lower-level dialog HMR wiring trace (module-realm count, NativeDialog + * instance count, registry hits/misses). Distinct from `hmrDialogLog` + * for greppability — both fan into the same Trace category so a single + * `Trace.setCategories(NativeScriptDebug.hmrTraceCategory)` toggle + * surfaces them all. */ function hmrDialogDiag(message: string): void { if (!isAngularHmrEnabled()) { return; } - console.info(`[ns-hmr-diag][dialog] ${message}`); + if (!NativeScriptDebug.isLogEnabled()) { + return; + } + NativeScriptDebug.hmrLog(`[dialog-diag] ${message}`); } /** diff --git a/packages/angular/src/lib/hmr-class-registry.spec.ts b/packages/angular/src/lib/hmr-class-registry.spec.ts index 0187fdf7..19d32907 100644 --- a/packages/angular/src/lib/hmr-class-registry.spec.ts +++ b/packages/angular/src/lib/hmr-class-registry.spec.ts @@ -1,3 +1,14 @@ +jest.mock('@nativescript/core', () => ({ + Trace: { + isEnabled: jest.fn(() => false), + isCategorySet: jest.fn(() => false), + write: jest.fn(), + error: jest.fn(), + messageType: { log: 0, info: 1, warn: 2, error: 3 }, + categories: { Style: 'NativeScript.Style' }, + }, +})); + import { _hmrDiagBumpCycle, _hmrDiagSnapshot, diff --git a/packages/angular/src/lib/hmr-class-registry.ts b/packages/angular/src/lib/hmr-class-registry.ts index 425cd1b1..b8db4561 100644 --- a/packages/angular/src/lib/hmr-class-registry.ts +++ b/packages/angular/src/lib/hmr-class-registry.ts @@ -49,6 +49,7 @@ */ import { isAngularDevMode, isAngularHmrEnabled } from './hmr-environment'; +import { NativeScriptDebug } from './trace'; const REGISTRY_KEY = '__NS_ANGULAR_HMR_CLASS_REGISTRY__'; const REGISTRY_META_KEY = '__NS_ANGULAR_HMR_CLASS_META__'; @@ -95,22 +96,24 @@ function getClassId(diag: ReturnType, cls: object): string { } return id; } +/** + * Class-registry HMR diagnostic. + */ function diagLog(message: string): void { if (!isAngularHmrEnabled()) return; - console.info(`[ns-hmr-diag][class-registry] ${message}`); + if (!NativeScriptDebug.isLogEnabled()) return; + NativeScriptDebug.hmrLog(`[class-registry] ${message}`); } /** - * Log helper that uses {@link isAngularDevMode} instead of - * {@link isAngularHmrEnabled} so it fires for messages that *must* be - * visible at module-load time, before NativeScript Vite's HMR globals - * have been set. The HMR-globals check would otherwise suppress the - * "registrar installed" message in the same window we're trying to - * diagnose. Production builds (`ngDevMode === false`) still skip the - * log. + * Log helper for "must surface at module-load time" messages — fires + * for any dev-mode build (not gated on the HMR-globals check) so the + * one-shot "registrar installed" line doesn't get suppressed by a + * module-load ordering race. */ function bootLog(message: string): void { if (!isAngularDevMode()) return; - console.info(`[ns-hmr-diag][class-registry] ${message}`); + if (!NativeScriptDebug.isLogEnabled()) return; + NativeScriptDebug.hmrLog(`[class-registry] ${message}`); } /** * Public so callers from application.ts can bump the cycle counter when diff --git a/packages/angular/src/lib/hmr-eager-services.spec.ts b/packages/angular/src/lib/hmr-eager-services.spec.ts index 5a92ea75..2e59bf1d 100644 --- a/packages/angular/src/lib/hmr-eager-services.spec.ts +++ b/packages/angular/src/lib/hmr-eager-services.spec.ts @@ -1,3 +1,14 @@ +jest.mock('@nativescript/core', () => ({ + Trace: { + isEnabled: jest.fn(() => false), + isCategorySet: jest.fn(() => false), + write: jest.fn(), + error: jest.fn(), + messageType: { log: 0, info: 1, warn: 2, error: 3 }, + categories: { Style: 'NativeScript.Style' }, + }, +})); + import { clearHmrEagerInstantiators, getRegisteredHmrEagerInstantiators, diff --git a/packages/angular/src/lib/hmr-eager-services.ts b/packages/angular/src/lib/hmr-eager-services.ts index 7ae73368..60820a41 100644 --- a/packages/angular/src/lib/hmr-eager-services.ts +++ b/packages/angular/src/lib/hmr-eager-services.ts @@ -1,4 +1,5 @@ import type { Injector } from '@angular/core'; +import { NativeScriptDebug } from './trace'; /** * Registry for HMR-aware services that need to be eagerly instantiated @@ -28,20 +29,15 @@ const REGISTRY_KEY = '__NS_HMR_EAGER_SERVICES__'; const REGISTER_KEY = '__NS_REGISTER_HMR_EAGER_SERVICE__'; /** - * Diagnostic: gate logging behind the same dev-only flag callers - * already use to decide whether to register at all. Production - * registrations are no-ops, so production log paths stay silent. + * Diagnostic helper. */ function eagerDiag(message: string): void { - // We can't import isAngularHmrEnabled here without creating a - // circular import (hmr-eager-services <- dialog-services <- - // application <- hmr-eager-services). Instead, key off the same - // globals isAngularHmrEnabled checks (kept inline for layering). const g = globalThis as { __NS_DEV_PLACEHOLDER_ROOT_EARLY__?: unknown; __NS_HMR_BOOT_COMPLETE__?: unknown; ngDevMode?: boolean }; const ngDev = (typeof g.ngDevMode === 'boolean') ? g.ngDevMode : true; const viteHmr = !!g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__ || !!g.__NS_HMR_BOOT_COMPLETE__; if (!(ngDev && viteHmr)) return; - console.info(`[ns-hmr-diag][eager] ${message}`); + if (!NativeScriptDebug.isLogEnabled()) return; + NativeScriptDebug.hmrLog(`[eager] ${message}`); } interface HmrEagerGlobals { From c94f620763c0602bfa369b321c6dc7e8110b7025 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 7 May 2026 20:24:15 -0700 Subject: [PATCH 12/19] feat: hmr cache service along with modal dialog handling --- .../src/lib/cdk/dialog/native-modal-ref.ts | 44 +- packages/angular/src/lib/hmr-cache-store.ts | 407 ++++++++++++++++++ packages/angular/src/lib/hmr-cache.service.ts | 247 +++++++++++ packages/angular/src/lib/public_api.ts | 11 + 4 files changed, 703 insertions(+), 6 deletions(-) create mode 100644 packages/angular/src/lib/hmr-cache-store.ts create mode 100644 packages/angular/src/lib/hmr-cache.service.ts diff --git a/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts b/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts index e2a8f6de..f823f522 100644 --- a/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts +++ b/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts @@ -20,6 +20,16 @@ export class NativeModalRef { portalOutlet: NativeScriptDomPortalOutlet; detachedLoaderRef: ComponentRef; modalViewRef: NgViewRef; + /** + * The actual NativeScript view passed to `parentView.showModal(...)`. + * + * For component portals this is the stable `targetView` ContentView + * wrapper that owns the Angular host PVC. For template portals it + * remains `modalViewRef.firstNativeLikeView` (the historical + * behavior). Keeping a direct reference avoids walking parent + * chains when programmatically closing the modal. + */ + modalView?: View; private _closeCallback: () => void; private _isDismissed = false; @@ -52,11 +62,18 @@ export class NativeModalRef { this._closeCallback = once(async () => { this.stateChanged.next({ state: 'closing' }); if (!this._isDismissed) { - this.modalViewRef.firstNativeLikeView?.closeModal(); + // Prefer `modalView` (the actual presented view) over the + // legacy `firstNativeLikeView`. Both paths ultimately reach + // the same `_closeModalCallback` via parent walk, but going + // through the presented view is one hop instead of three and + // works even if the rendered first root has been replaced by + // an HMR `ɵɵreplaceMetadata` cycle. + const closeTarget = this.modalView ?? this.modalViewRef.firstNativeLikeView; + closeTarget?.closeModal(); } await this.location?._closeModalNavigation(); // this.detachedLoaderRef?.destroy(); - if (this.modalViewRef?.firstNativeLikeView.isLoaded) { + if (this.modalViewRef?.firstNativeLikeView?.isLoaded) { fromEvent(this.modalViewRef.firstNativeLikeView, 'unloaded') .pipe(take(1)) .subscribe(() => this.stateChanged.next({ state: 'closed' })); @@ -117,20 +134,35 @@ export class NativeModalRef { attachComponentPortal(portal: ComponentPortal): ComponentRef { this.startModalNavigation(); + // `targetView` is a stable ContentView wrapper we own. The Angular + // component's host (a `ProxyViewContainer`) is attached as its + // content via the portal outlet below. We present `targetView` + // itself as the modal — *not* the first rendered template root — + // so that component-level HMR (`ɵɵreplaceMetadata`) can re-render + // into the PVC and the modal automatically displays the new + // content via the wrapper. Presenting the rendered first root + // directly worked for the initial open but left subsequent HMR + // updates rendering into a detached PVC, producing a blank modal. const targetView = new ContentView(); this.portalOutlet = new NativeScriptDomPortalOutlet(targetView, this._injector.get(ApplicationRef), this._injector); const componentRef = this.portalOutlet.attach(portal); componentRef.changeDetectorRef.detectChanges(); this.modalViewRef = new NgViewRef(componentRef); + this.modalView = targetView; if (this.modalViewRef.firstNativeLikeView !== this.modalViewRef.view) { (this.modalViewRef.view)._ngDialogRoot = this.modalViewRef.firstNativeLikeView; } - this.modalViewRef.firstNativeLikeView['__ng_modal_id__'] = this._id; - // if we don't detach the view from its parent, ios gets mad - this.modalViewRef.detachNativeLikeView(); + // Tag both the wrapper (the actual modal root) and the rendered + // first root so `getClosestDialog`'s parent walk finds the modal + // id regardless of whether it starts from a view inside the + // template or from the wrapper. + targetView['__ng_modal_id__'] = this._id; + if (this.modalViewRef.firstNativeLikeView) { + this.modalViewRef.firstNativeLikeView['__ng_modal_id__'] = this._id; + } const userOptions = this._config.nativeOptions || {}; - const modalView = this.modalViewRef.firstNativeLikeView; + const modalView = targetView; this.parentView.showModal(modalView, { context: null, ...userOptions, diff --git a/packages/angular/src/lib/hmr-cache-store.ts b/packages/angular/src/lib/hmr-cache-store.ts new file mode 100644 index 00000000..c03ab9fb --- /dev/null +++ b/packages/angular/src/lib/hmr-cache-store.ts @@ -0,0 +1,407 @@ +/** + * Framework-agnostic Hot-Module-Replacement state cache. + * + * The {@link HmrCacheStore} class is intentionally free of any Angular + * imports so a future `@nativescript/solid` (or any other framework + * binding) can lift this file as-is and wrap it with its own DI + * primitive. The Angular DI wrapper lives in + * `./hmr-cache.service.ts`. + * + * # What it does + * + * The NativeScript iOS runtime exposes a Vite-spec compliant + * `import.meta.hot` on every imported module (see + * `@nativescript/ios` → + * `runtime/HMRSupport.{h,mm}::InitializeImportMetaHot`). The runtime + * keeps a per-module persistent `data` object alive in C++ across V8 + * evaluation cycles and canonicalizes the module path so the same + * bucket survives the URL variations Vite cycles through during a + * save (HMR boot/live tags, versioned bridge paths, common script + * extensions). When `@nativescript/vite`'s Angular HMR client calls + * `globalThis.__nsRunHmrDispose()` before `__reboot_ng_modules__`, + * every registered `dispose(cb)` fires and is handed the same `data` + * object the next module evaluation will read from. + * + * `HmrCacheStore` rides on top of that primitive: + * + * 1. On construction, it copies any previously-stashed entries out + * of `import.meta.hot.data['ns-hmr-cache']` (or whatever + * `storageKey` the caller picked) into an in-memory `Map`. + * 2. It registers a single `import.meta.hot.dispose` callback that + * writes the in-memory `Map` back as a plain object before the + * next reboot. + * 3. Every `set` re-orders the key to the end of the `Map` (LRU); + * when `size > maxEntries`, the oldest entry is evicted. This + * stops a long dev session from accumulating unbounded state for + * features the developer no longer touches. + * 4. It subscribes to a custom HMR event (default + * `'ns:cache-invalidate'`) so a Vite plugin or dev server can + * push targeted cache evictions — e.g. "the OData schema for + * `/safety/forms` changed, drop anything that depends on it". + * + * In production / `--no-hmr` builds `import.meta.hot` is `undefined`, + * the store collapses to a pure in-memory cache that lives for the + * lifetime of the process, and the public API is identical so callers + * never need to special-case build modes. + * + * # Why a class and not a plain object + * + * Encapsulating the LRU bookkeeping behind named methods (`get`, + * `set`, `invalidate`, `scope`) lets us evolve the eviction policy + * (e.g. add TTLs, weighted entries, structured-clone enforcement) + * without touching every call site. The framework wrappers expose + * the same surface so app authors learn one API regardless of which + * framework they use. + */ + +const DEFAULT_MAX_ENTRIES = 256; +const DEFAULT_STORAGE_KEY = 'ns-hmr-cache'; +const DEFAULT_INVALIDATE_EVENT = 'ns:cache-invalidate'; + +/** + * Minimal Vite-spec `import.meta.hot` shape — declared locally so the + * package builds and types-checks without depending on `vite/client` + * (which would be a phantom dep for webpack-only consumers and a + * fragile assumption for contributors who don't have vite hoisted into + * their `node_modules`). The full Vite spec is much larger; we only + * type the methods we actually call. + * + * Compatible-by-construction with `vite/client`'s richer + * `ViteHotContext` — apps that DO reference `vite/client` get the + * superset and continue to work; we just don't require it. + */ +interface NsHotContext { + readonly data: Record; + dispose: (cb: (data: Record) => void) => void; + on?: (event: string, cb: (payload: unknown) => void) => void; +} + +/** + * Read `import.meta.hot` defensively so the package compiles even + * under TypeScript configs that don't extend an `ImportMeta` interface + * with a `hot` field. The cast goes through `unknown` so the local + * type is the only one in play; `vite/client`-aware apps still get + * their own typing on `import.meta.hot` at every call site outside + * this module. + */ +function readImportMetaHot(): NsHotContext | undefined { + try { + const meta = + typeof import.meta !== 'undefined' + ? (import.meta as unknown as { hot?: NsHotContext }) + : undefined; + return meta?.hot; + } catch { + return undefined; + } +} + +export interface HmrCacheStoreOptions { + /** + * Maximum number of entries to keep before LRU-evicting the + * oldest. Set to `0` (or any non-positive number) for unlimited. + * Default: `256`. + * + * Sized for the empirical "depth of features a developer touches in + * one dev session" — large enough that a working set of ~30 pages, + * each with a handful of cached fields, never trips eviction during + * normal hacking; small enough that a runaway producer (e.g. a + * scroll-driven loop that mints new keys) gets capped instead of + * leaking memory until the simulator OOMs. + */ + maxEntries?: number; + /** + * Key under which the cache is stashed on `import.meta.hot.data`. + * Apps that run multiple isolated cache stores (e.g. a feature- + * isolation plugin) can pick distinct keys to keep their state + * separate. Default: `'ns-hmr-cache'`. + */ + storageKey?: string; + /** + * Custom HMR event name the store listens for. Payload schema: + * + * ```ts + * { key?: string } + * ``` + * + * If `key` is provided, only that entry is dropped. If omitted, the + * entire cache is cleared. A Vite plugin sends events via the dev + * server's WebSocket (Vite spec + * [`server.ws.send`](https://vite.dev/guide/api-plugin.html#server-ws-send-and-server-ws-on)); + * the runtime's `__NS_DISPATCH_HOT_EVENT__` then forwards them to + * every `import.meta.hot.on` listener. Default: + * `'ns:cache-invalidate'`. + */ + invalidateEventName?: string; + /** + * Optional logger for diagnostic output. The store calls this with + * a single string per significant event (rehydrate, dispose, LRU + * evict, server-side invalidate). Default: no-op. + */ + log?: (message: string) => void; +} + +/** + * A namespaced view of an {@link HmrCacheStore}. Keys are + * automatically prefixed with `:`, so callers don't have to + * negotiate global key names. Returned by {@link HmrCacheStore.scope}. + */ +export interface HmrCacheScope { + readonly prefix: string; + get(key: string): T | undefined; + set(key: string, value: T): void; + has(key: string): boolean; + delete(key: string): void; + /** Drop every entry whose key starts with this scope's prefix. */ + clear(): void; + /** Number of entries owned by this scope. */ + size(): number; +} + +export class HmrCacheStore { + private readonly _map: Map; + private readonly _maxEntries: number; + private readonly _log: (message: string) => void; + + /** + * @param initialEntries Entries to seed the store with (typically + * the previous session's snapshot read from + * `import.meta.hot.data`). + * @param options See {@link HmrCacheStoreOptions}. + */ + constructor( + initialEntries: Iterable<[string, unknown]> = [], + options: HmrCacheStoreOptions = {} + ) { + this._map = new Map(initialEntries); + const requested = options.maxEntries; + this._maxEntries = + typeof requested === 'number' && requested > 0 + ? Math.floor(requested) + : 0; + this._log = options.log ?? (() => {}); + // Trim seed if it overshoots the configured ceiling — possible if + // a previous session ran with a larger `maxEntries` than this one. + this._enforceMaxEntries(); + } + + get(key: string): T | undefined { + if (!this._map.has(key)) { + return undefined; + } + // LRU touch: re-insert so the entry moves to the end of the + // insertion-order Map. + const value = this._map.get(key); + this._map.delete(key); + this._map.set(key, value); + return value as T; + } + + set(key: string, value: T): void { + if (this._map.has(key)) { + // Delete-then-set keeps insertion order monotonic. + this._map.delete(key); + } + this._map.set(key, value); + this._enforceMaxEntries(); + } + + has(key: string): boolean { + return this._map.has(key); + } + + delete(key: string): void { + this._map.delete(key); + } + + /** + * Drop a specific entry, or every entry when `key` is omitted. + * Equivalent to {@link delete} (with key) or {@link clear} (without) + * — exposed as a single method so callers and event handlers can + * forward an optional key without branching. + */ + invalidate(key?: string): void { + if (key === undefined || key === null) { + this.clear(); + return; + } + this.delete(key); + } + + /** Drop every cached entry. */ + clear(): void { + this._map.clear(); + } + + /** Total number of cached entries across all scopes. */ + size(): number { + return this._map.size; + } + + /** Snapshot of every key currently in the cache. */ + keys(): string[] { + return Array.from(this._map.keys()); + } + + /** + * Returns a namespaced view of this store. All keys passed to the + * returned object are auto-prefixed with `:`. Useful so + * each feature module can avoid stomping on neighbours' keys + * without repeating the prefix at every call site. + * + * @example + * ```ts + * const cache = createDefaultHmrCacheStore(); + * const submissions = cache.scope('page-my-submissions'); + * submissions.set('items', [...]); // stored under 'page-my-submissions:items' + * ``` + */ + scope(prefix: string): HmrCacheScope { + if (!prefix) { + throw new Error('[HmrCacheStore] scope() requires a non-empty prefix'); + } + const fullPrefix = `${prefix}:`; + const parent = this; + return { + prefix: fullPrefix, + get(key: string): T | undefined { + return parent.get(fullPrefix + key); + }, + set(key: string, value: T): void { + parent.set(fullPrefix + key, value); + }, + has(key: string): boolean { + return parent.has(fullPrefix + key); + }, + delete(key: string): void { + parent.delete(fullPrefix + key); + }, + clear(): void { + for (const k of parent.keys()) { + if (k.startsWith(fullPrefix)) { + parent.delete(k); + } + } + }, + size(): number { + let n = 0; + for (const k of parent.keys()) { + if (k.startsWith(fullPrefix)) { + n++; + } + } + return n; + }, + }; + } + + /** + * Serialize every entry into a plain object suitable for stashing + * on `import.meta.hot.data`. Used by the dispose callback in + * {@link createDefaultHmrCacheStore} and re-exported for callers + * that want to integrate with another persistence layer (e.g. a + * test harness that snapshots between cases). + */ + toObject(): Record { + const out: Record = {}; + for (const [k, v] of this._map.entries()) { + out[k] = v; + } + return out; + } + + private _enforceMaxEntries(): void { + if (this._maxEntries <= 0) { + return; + } + while (this._map.size > this._maxEntries) { + const oldestKey = this._map.keys().next().value; + if (oldestKey === undefined) { + return; + } + this._map.delete(oldestKey); + this._log( + `[HmrCacheStore] evicted oldest key="${oldestKey}" (size now ${this._map.size}/${this._maxEntries})` + ); + } + } +} + +/** + * Build an {@link HmrCacheStore} bound to the current module's + * `import.meta.hot` context — i.e. the store's data survives HMR + * reboots and listens for the `'ns:cache-invalidate'` custom event. + * + * Caller responsibility: invoke this from the module that "owns" the + * cache. `import.meta` is per-module, so the dispose callback will be + * registered against whichever module physically calls this function. + * In `@nativescript/angular` the canonical owner is + * `hmr-cache.service.ts`; in `@nativescript/solid` it would be the + * equivalent solid-side module. + * + * Returns a freshly-constructed store. Callers should treat it as a + * singleton — calling this twice from the same module yields two + * independent stores, which is almost never what you want. + */ +export function createDefaultHmrCacheStore( + options: HmrCacheStoreOptions = {} +): HmrCacheStore { + const storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY; + const invalidateEventName = + options.invalidateEventName ?? DEFAULT_INVALIDATE_EVENT; + const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + const log = options.log ?? (() => {}); + + const hot = readImportMetaHot(); + + // Read previous session's snapshot (if any) and seed the store. + const previousSnapshot = ((hot?.data as Record | undefined) + ?.[storageKey] ?? {}) as Record; + const previousEntries = Object.entries(previousSnapshot); + + const store = new HmrCacheStore(previousEntries, { + maxEntries, + storageKey, + invalidateEventName, + log, + }); + + if (hot) { + if (previousEntries.length) { + log( + `[HmrCacheStore] rehydrated ${previousEntries.length} entr${ + previousEntries.length === 1 ? 'y' : 'ies' + } from previous HMR session (storageKey="${storageKey}")` + ); + } + // Stash the live store back on dispose so the next reboot finds + // exactly what's in memory right now. + hot.dispose((data) => { + const snapshot = store.toObject(); + (data as Record)[storageKey] = snapshot; + log( + `[HmrCacheStore] dispose stashed ${Object.keys(snapshot).length} entr${ + Object.keys(snapshot).length === 1 ? 'y' : 'ies' + } (storageKey="${storageKey}")` + ); + }); + // Listen for server-side invalidation events. Vite plugins push + // these via `server.ws.send({ type: 'custom', event: 'ns:cache-invalidate', data: { key? } })`. + // The HMR client forwards them to `import.meta.hot.on`. If the + // runtime doesn't expose `on` (older `@nativescript/ios`), this + // is a clean no-op. + if (typeof hot.on === 'function') { + hot.on(invalidateEventName, (payload: { key?: string } | undefined) => { + const targetKey = payload?.key; + store.invalidate(targetKey); + log( + targetKey + ? `[HmrCacheStore] server-side invalidate dropped key="${targetKey}"` + : `[HmrCacheStore] server-side invalidate cleared all entries` + ); + }); + } + } + + return store; +} diff --git a/packages/angular/src/lib/hmr-cache.service.ts b/packages/angular/src/lib/hmr-cache.service.ts new file mode 100644 index 00000000..696009fb --- /dev/null +++ b/packages/angular/src/lib/hmr-cache.service.ts @@ -0,0 +1,247 @@ +import { Injectable } from '@angular/core'; +import { + createDefaultHmrCacheStore, + HmrCacheScope, + HmrCacheStore, + HmrCacheStoreOptions, +} from './hmr-cache-store'; +import { isAngularHmrEnabled } from './hmr-environment'; +import { NativeScriptDebug } from './trace'; + +/** + * Skip the API call your component already paid for last save. + * + * Inject {@link HmrCacheService} from any Angular component or service + * to read and write a per-app key/value cache that **survives the + * `__reboot_ng_modules__` cycle** triggered by every HMR file save. + * Backed by `@nativescript/ios`'s native `import.meta.hot.data` + * (`runtime/HMRSupport.{h,mm}`) and drained via + * `@nativescript/vite`'s `globalThis.__nsRunHmrDispose()` hook before + * Angular tears down its realm, so the same value the previous + * component instance produced is handed straight to the freshly- + * instantiated one — no network round-trip, no spinner flash. + * + * In production / `--no-hmr` builds `import.meta.hot` is `undefined` + * and the cache collapses to a plain in-memory object that lives for + * the lifetime of the process. The public API is identical, so callers + * never need to special-case build modes. + * + * @example Skip the initial fetch on save + * ```ts + * import { HmrCacheService } from '@nativescript/angular'; + * + * @Component({...}) + * export class MyComponent implements OnInit { + * private hmrCache = inject(HmrCacheService); + * + * ngOnInit() { + * const cached = this.hmrCache.get('my-feature:items'); + * if (cached) { + * this.applyResult(cached); + * return; + * } + * this.api.load().subscribe((result) => { + * this.hmrCache.set('my-feature:items', result); + * this.applyResult(result); + * }); + * } + * } + * ``` + * + * @example Namespaced via {@link scope} + * ```ts + * private cache = inject(HmrCacheService).scope('page-my-submissions'); + * // … + * this.cache.set('items', items); // → 'page-my-submissions:items' + * this.cache.get('items'); // ← 'page-my-submissions:items' + * ``` + * + * @example Server-side invalidation from a Vite plugin + * ```ts + * // vite.config.ts + * export default defineConfig({ + * plugins: [ + * { + * name: 'my-schema-watcher', + * configureServer(server) { + * server.watcher.on('change', (path) => { + * if (path.endsWith('schema.json')) { + * server.ws.send({ + * type: 'custom', + * event: 'ns:cache-invalidate', + * data: { key: 'my-feature:items' }, + * }); + * } + * }); + * }, + * }, + * ], + * }); + * ``` + * + * Memory ceiling: the cache LRU-evicts at 256 entries by default. Pass + * a custom ceiling via {@link configureHmrCache} if your app churns + * through more keys than that in a typical dev session, or set `0` for + * unlimited (unbounded growth — only safe for short-lived dev work). + * + * @see HmrCacheStore — the framework-agnostic engine. Stable enough to + * lift into `@nativescript/solid` / other framework bindings without + * modification. + */ +@Injectable({ providedIn: 'root' }) +export class HmrCacheService { + private readonly _store = getOrCreateSharedStore(); + + /** + * `true` when `import.meta.hot` is wired (i.e. NativeScript Vite HMR + * is active and `@nativescript/ios` is recent enough to expose + * `import.meta.hot.data`). `false` in production / `--no-hmr` / + * legacy webpack builds. + * + * Most callers should NOT branch on this — the public API works + * identically in both cases. Use it only when you want to opt OUT + * of caching in production (e.g. always fetch fresh data when not + * developing). + */ + readonly isHmr: boolean = isAngularHmrEnabled() && hasImportMetaHot(); + + get(key: string): T | undefined { + return this._store.get(key); + } + + set(key: string, value: T): void { + this._store.set(key, value); + } + + has(key: string): boolean { + return this._store.has(key); + } + + delete(key: string): void { + this._store.delete(key); + } + + /** + * Drop a single entry, or every entry when `key` is omitted. Same + * shape as the `'ns:cache-invalidate'` HMR-event payload the store + * listens for, so application code can call this directly to mirror + * a server-side eviction. + */ + invalidate(key?: string): void { + this._store.invalidate(key); + } + + /** Drop every cached entry. Equivalent to `invalidate()` with no key. */ + clear(): void { + this._store.clear(); + } + + /** Total number of entries across every scope. */ + size(): number { + return this._store.size(); + } + + /** Snapshot of every key currently cached. Useful for debug overlays. */ + keys(): string[] { + return this._store.keys(); + } + + /** + * Returns a namespaced view of the cache. All `get` / `set` / + * `has` / `delete` calls on the returned object are auto-prefixed + * with `:`. Recommended over global keys so feature + * modules don't accidentally collide. + * + * @example + * ```ts + * private cache = inject(HmrCacheService).scope('page-my-submissions'); + * // … + * this.cache.set('items', items); // → 'page-my-submissions:items' + * ``` + */ + scope(scopeName: string): HmrCacheScope { + return this._store.scope(scopeName); + } +} + +/** + * Override the default cache configuration. Must be called BEFORE the + * first injection of {@link HmrCacheService} (i.e. before Angular + * bootstrap, or as the very first statement in `main.ts`); otherwise + * the call is a no-op because the singleton store has already been + * built with the previous (or default) options. + * + * Typical use case: bumping `maxEntries` for a large multi-feature + * monorepo dev session, or pointing a custom `invalidateEventName` at + * a Vite plugin that prefixes its events with the project name. + * + * Returns `true` if the configuration was applied, `false` if the + * store had already been instantiated by an earlier injection. + */ +export function configureHmrCache(options: HmrCacheStoreOptions): boolean { + if (sharedStore !== null) { + return false; + } + pendingOptions = options; + return true; +} + +/** + * Read-only access to the underlying {@link HmrCacheStore}. Exposed + * for advanced integrations that want to reuse the LRU + dispose + + * server-side-invalidate plumbing without going through Angular's + * dependency injection (e.g. a non-component utility that's loaded + * before the Angular platform has bootstrapped). Application code + * should prefer {@link HmrCacheService}. + */ +export function getHmrCacheStore(): HmrCacheStore { + return getOrCreateSharedStore(); +} + +let sharedStore: HmrCacheStore | null = null; +let pendingOptions: HmrCacheStoreOptions | null = null; + +function getOrCreateSharedStore(): HmrCacheStore { + if (sharedStore !== null) { + return sharedStore; + } + const options: HmrCacheStoreOptions = { + log: (msg) => { + // Keep cache diagnostics on the same channel as other HMR + // helpers so devs can grep one trace category and see the full + // picture during a save cycle. NativeScriptDebug.bootstrapLog + // is the conventional sink for HMR-adjacent lifecycle logs. + try { + NativeScriptDebug.bootstrapLog(msg); + } catch { + // Defensive: never crash the cache if the trace channel is + // unavailable (e.g. tests that import this file in isolation). + } + }, + ...pendingOptions, + }; + sharedStore = createDefaultHmrCacheStore(options); + pendingOptions = null; + return sharedStore; +} + +function hasImportMetaHot(): boolean { + try { + const meta = + typeof import.meta !== 'undefined' + ? (import.meta as unknown as { hot?: unknown }) + : undefined; + return !!meta?.hot; + } catch { + return false; + } +} + +// Re-export the engine types so consumers don't have to dig into the +// internal sub-path. Mirror this in the `public_api.ts` barrel. +export { + createDefaultHmrCacheStore, + HmrCacheScope, + HmrCacheStore, + HmrCacheStoreOptions, +}; diff --git a/packages/angular/src/lib/public_api.ts b/packages/angular/src/lib/public_api.ts index fa617f87..78d6fa0c 100644 --- a/packages/angular/src/lib/public_api.ts +++ b/packages/angular/src/lib/public_api.ts @@ -42,6 +42,17 @@ export * from './file-system'; export * from './nativescript-common.module'; export * from './loading.service'; export * from './detached-loader-utils'; +export { + HmrCacheService, + configureHmrCache, + getHmrCacheStore, + // Re-exports of the framework-agnostic engine for advanced + // integrations (e.g. lifting into non-Angular framework bindings). + HmrCacheStore, + HmrCacheStoreOptions, + HmrCacheScope, + createDefaultHmrCacheStore, +} from './hmr-cache.service'; // export * from './router/router.module'; export { AppLaunchView, From b387f2cdd5afa4f950ce71104b946d74f2fe7269 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 9 May 2026 07:59:05 -0700 Subject: [PATCH 13/19] fix: improve route handling on hmr updates --- .../lib/legacy/router/hmr-route-state-core.ts | 21 +++++ .../router/hmr-route-state-tracker.spec.ts | 87 ++++++++++++++++++- .../lib/legacy/router/hmr-route-state.spec.ts | 21 +++++ .../src/lib/legacy/router/hmr-route-state.ts | 48 +++++++++- 4 files changed, 175 insertions(+), 2 deletions(-) diff --git a/packages/angular/src/lib/legacy/router/hmr-route-state-core.ts b/packages/angular/src/lib/legacy/router/hmr-route-state-core.ts index 42bda6d8..0a1df3fe 100644 --- a/packages/angular/src/lib/legacy/router/hmr-route-state-core.ts +++ b/packages/angular/src/lib/legacy/router/hmr-route-state-core.ts @@ -222,6 +222,27 @@ export function clearAngularHmrRouteHistory(): void { delete g[HISTORY_KEY]; } +/** + * Replace the entire live back-stack mirror with a single URL. Mirrors + * NativeScript's `clearHistory: true` navigation option, which collapses + * the native page stack down to the destination — without this, the HMR + * snapshot would still carry every URL the user passed through before + * the reset (e.g. login screens that auth-gates now hide), and the next + * reboot would walk through every one of them as a forward navigation. + * + * An empty / unparseable `value` clears the mirror entirely. + */ +export function resetAngularHmrRouteHistoryToUrl(value: unknown): string | null { + const url = normalizeAngularHmrRouteUrl(value); + if (!url) { + writeHistoryArray(HISTORY_KEY, []); + return null; + } + + writeHistoryArray(HISTORY_KEY, [url]); + return url; +} + /** * Snapshot the live back-stack mirror under the pending-history slot so the * next bootstrap can read it. Called from the HMR capture hook. diff --git a/packages/angular/src/lib/legacy/router/hmr-route-state-tracker.spec.ts b/packages/angular/src/lib/legacy/router/hmr-route-state-tracker.spec.ts index 868020c6..37450fd4 100644 --- a/packages/angular/src/lib/legacy/router/hmr-route-state-tracker.spec.ts +++ b/packages/angular/src/lib/legacy/router/hmr-route-state-tracker.spec.ts @@ -46,22 +46,37 @@ interface RouterEvent { interface RouterMock { events: Subject; url: string; + // Mirrors `Router.getCurrentNavigation()` enough that the tracker can read + // `extras.clearHistory` off it — the same hook NS's page-router-outlet uses + // to enable `clearHistory: true` end-to-end. + currentNavigation: { extras?: { clearHistory?: boolean } } | null; + getCurrentNavigation(): { extras?: { clearHistory?: boolean } } | null; emitNavigationEnd(url: string): void; emitNavigationStart( url: string, options?: { trigger?: 'imperative' | 'popstate' | 'hashchange'; restoredState?: { navigationId: number } | null; + clearHistory?: boolean; }, ): void; } function createRouterMock(initialUrl: string): RouterMock { const events = new Subject(); - return { + const router: RouterMock = { events, url: initialUrl, + currentNavigation: null, + getCurrentNavigation() { + return this.currentNavigation; + }, emitNavigationStart(url, options) { + // Mirror Angular: `getCurrentNavigation()` returns the active navigation + // between NavigationStart and NavigationEnd, then is cleared. + this.currentNavigation = { + extras: options?.clearHistory ? { clearHistory: true } : {}, + }; events.next( new MockNavigationStart( 1, @@ -74,8 +89,10 @@ function createRouterMock(initialUrl: string): RouterMock { emitNavigationEnd(url) { this.url = url; events.next(new MockNavigationEnd(1, url, url) as unknown as RouterEvent); + this.currentNavigation = null; }, }; + return router; } describe('NativeScriptAngularHmrRouteTracker', () => { @@ -170,6 +187,74 @@ describe('NativeScriptAngularHmrRouteTracker', () => { }); }); + describe('clearHistory navigation extra', () => { + it('collapses the live mirror to the destination URL when clearHistory is set', () => { + // Reproduces the canonical HeyKiddo auth flow: user passes through + // /signup-landing, /login on the way to a clearHistory navigation + // that drops the iOS back-stack down to /talk. Without mirroring + // that on the HMR side, a subsequent .ts edit replays NavigationEnd + // for every URL the user passed through — including login pages + // that the auth-gate now redirects away from. + const router = createRouterMock('/'); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const tracker = new NativeScriptAngularHmrRouteTracker(router as never); + + router.emitNavigationStart('/signup-landing'); + router.emitNavigationEnd('/signup-landing'); + router.emitNavigationStart('/login'); + router.emitNavigationEnd('/login'); + + expect(readAngularHmrRouteHistory()).toEqual(['/signup-landing', '/login']); + + router.emitNavigationStart('/talk/(todayTab:today)', { clearHistory: true }); + router.emitNavigationEnd('/talk/(todayTab:today)'); + + expect(readAngularHmrRouteHistory()).toEqual(['/talk/(todayTab:today)']); + }); + + it('keeps subsequent non-clearHistory navigations on top of the destination after a reset', () => { + // After a clearHistory reset, normal forward navigation (e.g. tab + // switches) should still grow the mirror so the back-stack is + // restored across HMR cycles for the post-reset session. + const router = createRouterMock('/'); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const tracker = new NativeScriptAngularHmrRouteTracker(router as never); + + router.emitNavigationStart('/login'); + router.emitNavigationEnd('/login'); + router.emitNavigationStart('/talk/(todayTab:today)', { clearHistory: true }); + router.emitNavigationEnd('/talk/(todayTab:today)'); + router.emitNavigationStart('/talk/(progressTab:progress//todayTab:today)'); + router.emitNavigationEnd('/talk/(progressTab:progress//todayTab:today)'); + + expect(readAngularHmrRouteHistory()).toEqual([ + '/talk/(todayTab:today)', + '/talk/(progressTab:progress//todayTab:today)', + ]); + }); + + it('does not throw when the router mock omits getCurrentNavigation', () => { + // Defensive: the tracker should fall back to the legacy push path on + // routers that don't expose `getCurrentNavigation()`. This protects + // older Angular versions, edge-case mocks, and hardened proxy wrappers + // that strip non-public methods. + const router = createRouterMock('/'); + // Force a router shape without the API; the tracker must treat it + // as "no clearHistory". + (router as { getCurrentNavigation?: unknown }).getCurrentNavigation = undefined; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const tracker = new NativeScriptAngularHmrRouteTracker(router as never); + + router.emitNavigationStart('/login', { clearHistory: true }); + router.emitNavigationEnd('/login'); + + expect(readAngularHmrRouteHistory()).toEqual(['/login']); + }); + }); + describe('clear-after-snapshot integration', () => { it('a snapshot during HMR reboot leaves the live mirror empty for the next bootstrap to rebuild', () => { // Cycle 1: real boot, walk forward to /profile. diff --git a/packages/angular/src/lib/legacy/router/hmr-route-state.spec.ts b/packages/angular/src/lib/legacy/router/hmr-route-state.spec.ts index 55f9815b..6948413c 100644 --- a/packages/angular/src/lib/legacy/router/hmr-route-state.spec.ts +++ b/packages/angular/src/lib/legacy/router/hmr-route-state.spec.ts @@ -16,6 +16,7 @@ import { readAngularHmrPendingStartPath, readAngularHmrRouteHistory, replaceAngularHmrRouteHistoryTop, + resetAngularHmrRouteHistoryToUrl, snapshotAngularHmrRouteHistory, writeAngularHmrRouteState, } from './hmr-route-state-core'; @@ -101,6 +102,26 @@ describe('Angular HMR route state', () => { expect(readAngularHmrRouteHistory()).toEqual(['/talk/(todayTab:today)', '/profile?tab=goals']); }); + it('collapses the live mirror to a single URL on a clearHistory navigation', () => { + // Mirrors NativeScript's `clearHistory: true` extra: the user-visible + // back-stack is reset to just the destination, so the HMR mirror + // must follow suit. + pushAngularHmrRouteHistoryEntry('/'); + pushAngularHmrRouteHistoryEntry('/signup-landing'); + pushAngularHmrRouteHistoryEntry('/login'); + + expect(resetAngularHmrRouteHistoryToUrl('/talk/(todayTab:today)')).toBe('/talk/(todayTab:today)'); + expect(readAngularHmrRouteHistory()).toEqual(['/talk/(todayTab:today)']); + }); + + it('clears the live mirror entirely when reset is called with an unparseable URL', () => { + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + pushAngularHmrRouteHistoryEntry('/profile'); + + expect(resetAngularHmrRouteHistoryToUrl(undefined)).toBeNull(); + expect(readAngularHmrRouteHistory()).toEqual([]); + }); + it('snapshots the live mirror into the pending slot for the next bootstrap', () => { pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); pushAngularHmrRouteHistoryEntry('/profile'); diff --git a/packages/angular/src/lib/legacy/router/hmr-route-state.ts b/packages/angular/src/lib/legacy/router/hmr-route-state.ts index 7b955008..39229bbc 100644 --- a/packages/angular/src/lib/legacy/router/hmr-route-state.ts +++ b/packages/angular/src/lib/legacy/router/hmr-route-state.ts @@ -9,6 +9,7 @@ import { pushAngularHmrRouteHistoryEntry, readAngularHmrPendingStartPath, replaceAngularHmrRouteHistoryTop, + resetAngularHmrRouteHistoryToUrl, snapshotAngularHmrRouteHistory, writeAngularHmrRouteState, } from './hmr-route-state-core'; @@ -29,10 +30,41 @@ export { readAngularHmrPendingRouteHistory, readAngularHmrRouteHistory, replaceAngularHmrRouteHistoryTop, + resetAngularHmrRouteHistoryToUrl, snapshotAngularHmrRouteHistory, } from './hmr-route-state-core'; export { readAngularHmrPendingStartPath } from './hmr-route-state-core'; +/** + * Read NativeScript's `clearHistory: true` navigation extra off the active + * Angular navigation. Defensive against test mocks and bare `Router`-like + * shapes that don't expose `getCurrentNavigation` (e.g. earlier Angular + * versions and the unit-test mocks in `hmr-route-state-tracker.spec.ts`). + * + * `clearHistory` is the NativeScript-only signal that + * `NSLocationStrategy._beginPageNavigation` uses to collapse the native page + * stack down to the destination. We mirror that on the HMR side so a + * subsequent reboot doesn't replay URLs the user can no longer reach (the + * canonical example: `/`, `/signup-landing`, `/login` after the auth flow + * navigated to `/talk/(todayTab:today)` with `clearHistory: true`). + */ +function readClearHistoryFromRouter(router: Router): boolean { + const getCurrentNavigation = (router as { getCurrentNavigation?: () => unknown }).getCurrentNavigation; + if (typeof getCurrentNavigation !== 'function') { + return false; + } + + let navigation: unknown; + try { + navigation = getCurrentNavigation.call(router); + } catch { + return false; + } + + const extras = (navigation as { extras?: { clearHistory?: unknown } } | null | undefined)?.extras; + return !!extras?.clearHistory; +} + @Injectable() export class NativeScriptAngularHmrRouteTracker implements OnDestroy { private subscription?: Subscription; @@ -42,6 +74,12 @@ export class NativeScriptAngularHmrRouteTracker implements OnDestroy { // `NavigationEnd` we can pop our mirror instead of pushing a duplicate entry. private currentNavigationIsPopstate = false; private currentNavigationReplaceUrl = false; + // Tracks whether the active navigation was started with NativeScript's + // `clearHistory: true` extra (read off `router.getCurrentNavigation()` at + // `NavigationStart`). When set, the matching `NavigationEnd` collapses the + // mirror down to just the destination URL — see + // `resetAngularHmrRouteHistoryToUrl` for the rationale. + private currentNavigationClearsHistory = false; constructor(private readonly router: Router) { if (!isAngularHmrEnabled()) { @@ -54,6 +92,7 @@ export class NativeScriptAngularHmrRouteTracker implements OnDestroy { if (event instanceof NavigationStart) { this.currentNavigationIsPopstate = event.navigationTrigger === 'popstate'; this.currentNavigationReplaceUrl = !!event.restoredState; + this.currentNavigationClearsHistory = readClearHistoryFromRouter(this.router); return; } @@ -63,7 +102,13 @@ export class NativeScriptAngularHmrRouteTracker implements OnDestroy { source: 'navigation-end', }); - if (this.currentNavigationIsPopstate) { + if (this.currentNavigationClearsHistory) { + // NativeScript collapsed the native page stack to this single + // destination. Mirror that on the HMR side so a future reboot + // replays only what the user can still navigate back through — + // not every URL they passed through before the reset. + resetAngularHmrRouteHistoryToUrl(url); + } else if (this.currentNavigationIsPopstate) { // The user (or NSLocationStrategy.back()) walked the back-stack down // by one page; mirror that by dropping the top of our snapshot so a // subsequent HMR reboot doesn't carry the popped page back into view. @@ -76,6 +121,7 @@ export class NativeScriptAngularHmrRouteTracker implements OnDestroy { this.currentNavigationIsPopstate = false; this.currentNavigationReplaceUrl = false; + this.currentNavigationClearsHistory = false; } }); } From ae57186d4a9cd260f661147aa203e02b465d7b00 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Mon, 11 May 2026 15:29:40 -0700 Subject: [PATCH 14/19] fix: backwards compat with webpack builds --- packages/angular/src/lib/hmr-cache-store.ts | 16 +++++++++++----- packages/angular/src/lib/hmr-cache.service.ts | 11 ++++++----- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/angular/src/lib/hmr-cache-store.ts b/packages/angular/src/lib/hmr-cache-store.ts index c03ab9fb..2865e506 100644 --- a/packages/angular/src/lib/hmr-cache-store.ts +++ b/packages/angular/src/lib/hmr-cache-store.ts @@ -83,14 +83,20 @@ interface NsHotContext { * type is the only one in play; `vite/client`-aware apps still get * their own typing on `import.meta.hot` at every call site outside * this module. + * + * Webpack-CommonJS compatibility: every `import.meta` reference here + * is a member expression (`import.meta['hot']`). Webpack statically + * rewrites both dot- and bracket-style member access to `undefined` + * in CommonJS output, so the emitted bundle never carries a literal + * bare `import.meta` token. A bare `import.meta` would survive into + * the bundle and crash V8 with "Cannot use 'import.meta' outside a + * module" the moment the chunk is `require()`d. Vite leaves + * `import.meta['hot']` intact and resolves it to the per-module hot + * context, so the same code works in both pipelines. */ function readImportMetaHot(): NsHotContext | undefined { try { - const meta = - typeof import.meta !== 'undefined' - ? (import.meta as unknown as { hot?: NsHotContext }) - : undefined; - return meta?.hot; + return (import.meta as unknown as { hot?: NsHotContext })['hot']; } catch { return undefined; } diff --git a/packages/angular/src/lib/hmr-cache.service.ts b/packages/angular/src/lib/hmr-cache.service.ts index 696009fb..75ea88dd 100644 --- a/packages/angular/src/lib/hmr-cache.service.ts +++ b/packages/angular/src/lib/hmr-cache.service.ts @@ -227,11 +227,12 @@ function getOrCreateSharedStore(): HmrCacheStore { function hasImportMetaHot(): boolean { try { - const meta = - typeof import.meta !== 'undefined' - ? (import.meta as unknown as { hot?: unknown }) - : undefined; - return !!meta?.hot; + // Member-expression access only — webpack rewrites `import.meta['hot']` + // to `undefined` in CommonJS bundles (so `!!undefined` → `false`), + // while Vite leaves it as the per-module hot context. A bare + // `typeof import.meta` would survive into the bundle and crash V8 + // with "Cannot use 'import.meta' outside a module" on `require()`. + return !!(import.meta as unknown as { hot?: unknown })['hot']; } catch { return false; } From ff84f87d1667ca17a7fa2d3482f2625fc1fbeb90 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 24 May 2026 14:43:10 -0700 Subject: [PATCH 15/19] feat: hmr state with modals and router --- packages/angular/src/lib/application.ts | 55 ++++ .../lib/cdk/dialog/modal-host-props.spec.ts | 291 ++++++++++++++++++ .../src/lib/cdk/dialog/modal-host-props.ts | 141 +++++++++ .../src/lib/cdk/dialog/native-modal-ref.ts | 23 ++ .../legacy/router/hmr-route-replay.spec.ts | 49 +-- .../src/lib/legacy/router/hmr-route-replay.ts | 18 +- .../lib/legacy/router/hmr-route-state-core.ts | 91 ++++-- .../lib/legacy/router/hmr-route-state.spec.ts | 38 ++- 8 files changed, 648 insertions(+), 58 deletions(-) create mode 100644 packages/angular/src/lib/cdk/dialog/modal-host-props.spec.ts create mode 100644 packages/angular/src/lib/cdk/dialog/modal-host-props.ts diff --git a/packages/angular/src/lib/application.ts b/packages/angular/src/lib/application.ts index 490f4328..035d7b13 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -749,6 +749,61 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { disposeLastModules('hotreload'); disposePlatform('hotreload'); }; + // Pre-import hook for HMR runtimes. Must be called BEFORE the changed + // component modules are re-imported, otherwise their ɵɵdefineComponent + // calls fire against the OLD `GENERATED_COMP_IDS` map and Angular emits + // a benign-but-noisy NG0912 "Component ID generation collision" warning + // for every component the user has touched. Calling + // `ɵresetCompiledComponents` here clears the map (and the related + // ownerNgModule / verifiedNgModule WeakMaps) so the fresh defs register + // into an empty table. + // + // The post-reboot call inside `bootstrapRoot('hotreload')` remains in + // place as a safety net: a project that doesn't wire its HMR runtime to + // this hook still gets the reset (just one cycle late, after the warning + // has already surfaced). + global['__reset_ng_compiled_components__'] = () => { + resetAngularHmrCompiledComponents(getAngularCoreForHmrReset(AngularCore as any, globalThis as any)); + }; + + // Suppress benign HMR-induced NG0912 ("Component ID generation collision") + // warnings. On a `.ts` edit Angular Live Reload's + // `ListenNowComponent_UpdateMetadata` function in the freshly re-imported + // module calls `ɵɵreplaceMetadata` → `ɵɵdefineComponent` → `getComponentId` + // against a class that shares its name with the just-rebooted instance but + // not its identity (one comes from the route-loaded module, the other from + // the dynamically-fetched `/@ng/component?c=…` metadata chunk). The check + // surfaces every component the user touches as a noisy warning even though + // there's no real collision — same logical class, two transient identities + // during the HMR cycle. + // + // Real collisions — different classes that happen to hash to the same id — + // produce a warning where `'X' and 'Y'` (different class names) appear in + // the message. We only filter when both names match, so genuine duplicates + // still reach the user. + // + // Install once per process; the filter self-detaches if the warning text + // changes shape (defensive against future Angular wording tweaks). + (() => { + const w: { __NS_ANGULAR_NG0912_FILTER_INSTALLED__?: boolean } = global as any; + if (w.__NS_ANGULAR_NG0912_FILTER_INSTALLED__) return; + w.__NS_ANGULAR_NG0912_FILTER_INSTALLED__ = true; + const origWarn = console.warn.bind(console); + // Pattern: "Components 'Foo' and 'Bar' with selector 'xyz'" — capture + // both class names and compare. We suppress only when they're identical + // (the HMR pseudo-collision signature). + const NG0912_NAME_MATCH = /NG0912[\s\S]*?Components '([^']+)' and '([^']+)' with selector/; + console.warn = (...args: any[]) => { + const msg = String(args[0] ?? ''); + if (msg.includes('NG0912')) { + const m = NG0912_NAME_MATCH.exec(msg); + if (m && m[1] === m[2]) { + return; + } + } + origWarn(...args); + }; + })(); global['__reboot_ng_modules__'] = (shouldDisposePlatform: boolean = false) => { // Bump the global HMR cycle counter so subsequent diagnostic log // lines (class registry, dialog services) can be cross-referenced diff --git a/packages/angular/src/lib/cdk/dialog/modal-host-props.spec.ts b/packages/angular/src/lib/cdk/dialog/modal-host-props.spec.ts new file mode 100644 index 00000000..a4a0997b --- /dev/null +++ b/packages/angular/src/lib/cdk/dialog/modal-host-props.spec.ts @@ -0,0 +1,291 @@ +import { AddViewHost, installPvcModalHostPropPropagation, ModalHostView, propagateModalHostPropsToDescendants, PVC_ADD_VIEW_WRAPPED_MARKER } from './modal-host-props'; + +/** + * Minimal stand-in for a NativeScript `View` shape that supports + * the `eachChildView` walk these helpers rely on. Real `View` + * instances satisfy `ModalHostView` trivially; using a plain JS + * stub here keeps the spec free of `@nativescript/core` (which + * cannot load in the Jest Node runner without a runtime stub). + */ +class FakeView implements ModalHostView, AddViewHost { + _dialogFragment?: unknown; + viewController?: unknown; + children: FakeView[] = []; + + constructor(public name: string = 'view') {} + + eachChildView(callback: (child: ModalHostView) => boolean): void { + for (const child of this.children) { + if (callback(child) === false) { + return; + } + } + } + + // ProxyViewContainer-shaped add: links child into `children` and is + // the call site we wrap in `installPvcModalHostPropPropagation`. + _addView(view: ModalHostView, _atIndex?: number): void { + this.children.push(view as FakeView); + } +} + +function buildSubtree(...names: string[]): FakeView[] { + return names.map((n) => new FakeView(n)); +} + +describe('modal-host-props', () => { + describe('propagateModalHostPropsToDescendants', () => { + it('mirrors `_dialogFragment` and `viewController` from the wrapper onto every native-like descendant', () => { + // Wrapper holds the canonical references NS core stamped on + // it during `_showNativeModalView`. The PVC, the template + // root, and any nested child must end up with the same + // references so user template code (`onLoaded($event)` → + // `args.object._dialogFragment.getDialog()`) reads the real + // host objects instead of `undefined`. + const dialogFragment = { kind: 'DialogFragment' }; + const viewController = { kind: 'UIViewController' }; + const wrapper = new FakeView('wrapper'); + wrapper._dialogFragment = dialogFragment; + wrapper.viewController = viewController; + + const pvc = new FakeView('pvc'); + const stackLayout = new FakeView('stack'); + const label = new FakeView('label'); + wrapper.children = [pvc]; + pvc.children = [stackLayout]; + stackLayout.children = [label]; + + propagateModalHostPropsToDescendants(wrapper, wrapper); + + expect(pvc._dialogFragment).toBe(dialogFragment); + expect(pvc.viewController).toBe(viewController); + expect(stackLayout._dialogFragment).toBe(dialogFragment); + expect(stackLayout.viewController).toBe(viewController); + expect(label._dialogFragment).toBe(dialogFragment); + expect(label.viewController).toBe(viewController); + }); + + it('never overwrites the wrapper itself even when the walk starts at the wrapper', () => { + // NS core owns the wrapper's host-prop assignment; mirroring + // would only let our copy drift out of sync (e.g. across an + // HMR re-render where NS reassigns on the wrapper but our + // mirror lags behind). + const dialogFragment = { kind: 'DialogFragment' }; + const wrapper = new FakeView('wrapper'); + wrapper._dialogFragment = dialogFragment; + const sentinel = wrapper._dialogFragment; + + propagateModalHostPropsToDescendants(wrapper, wrapper); + + // Identity preserved: we did not even touch the slot. + expect(wrapper._dialogFragment).toBe(sentinel); + }); + + it('no-ops when the modal has not been shown yet (wrapper has neither host prop)', () => { + // Before `showModal` returns, NS core has not stamped the + // host props. The helper must not write `undefined` onto the + // descendants — that would shadow a real value if mirroring + // ever races a later call. + const wrapper = new FakeView('wrapper'); + const stack = new FakeView('stack'); + wrapper.children = [stack]; + stack._dialogFragment = { kind: 'preexisting' }; + + propagateModalHostPropsToDescendants(wrapper, wrapper); + + expect(stack._dialogFragment).toEqual({ kind: 'preexisting' }); + }); + + it('no-ops when the modal has already closed (NS sets both props to null on the wrapper)', () => { + // NS clears `_dialogFragment` / `viewController` to `null` + // on close. Mirroring after that would persist stale + // references on descendants past their useful lifetime — + // worse, it would *clear* user-set values in the same slot. + const wrapper = new FakeView('wrapper'); + wrapper._dialogFragment = null; + wrapper.viewController = null; + const stack = new FakeView('stack'); + const userStash = { kind: 'user-stashed' }; + stack._dialogFragment = userStash; + wrapper.children = [stack]; + + propagateModalHostPropsToDescendants(wrapper, wrapper); + + expect(stack._dialogFragment).toBe(userStash); + }); + + it('skips redundant writes when the descendant already holds the same reference (idempotency)', () => { + // Repeat calls are expected during HMR: the initial-open + // propagate runs, then the PVC `_addView` wrap re-runs the + // walk for each child added during a re-render. Re-assigning + // the same identity is harmless but Object.defineProperty + // tricks (some host views have setter side effects on + // assignment) would fire spurious updates. + const dialogFragment = { kind: 'DialogFragment' }; + const wrapper = new FakeView('wrapper'); + wrapper._dialogFragment = dialogFragment; + const stack = new FakeView('stack'); + stack._dialogFragment = dialogFragment; + wrapper.children = [stack]; + let setterCalls = 0; + Object.defineProperty(stack, '_dialogFragment', { + get: () => dialogFragment, + set: () => { + setterCalls++; + }, + }); + + propagateModalHostPropsToDescendants(wrapper, wrapper); + + expect(setterCalls).toBe(0); + }); + + it('walks `root` directly when called with a non-wrapper root (the HMR `_addView` pre-hook entry point)', () => { + // The PVC `_addView` wrap calls this with `root === viewBeingAdded`. + // We must propagate onto the just-added subtree even though + // it is not yet a child of the wrapper. + const dialogFragment = { kind: 'DialogFragment' }; + const wrapper = new FakeView('wrapper'); + wrapper._dialogFragment = dialogFragment; + const newTemplateRoot = new FakeView('new-template-root'); + const nested = new FakeView('nested'); + newTemplateRoot.children = [nested]; + + propagateModalHostPropsToDescendants(wrapper, newTemplateRoot); + + expect(newTemplateRoot._dialogFragment).toBe(dialogFragment); + expect(nested._dialogFragment).toBe(dialogFragment); + }); + + it('is safe with nullish inputs', () => { + // Defensive: helpers are called from real Angular tear-down + // paths where either input can briefly be `undefined`. + expect(() => propagateModalHostPropsToDescendants(undefined, undefined)).not.toThrow(); + expect(() => propagateModalHostPropsToDescendants(undefined, new FakeView())).not.toThrow(); + expect(() => propagateModalHostPropsToDescendants(new FakeView(), undefined)).not.toThrow(); + }); + }); + + describe('installPvcModalHostPropPropagation', () => { + it('mirrors host-props onto each child added after install (HMR `ɵɵreplaceMetadata` rerender simulation)', () => { + // Setup: wrapper already has host props (modal is open). + // We install on the host PVC, then simulate Angular's HMR + // re-render path: add a fresh template root via the wrapped + // `_addView`. The new child *must* have `_dialogFragment` + // set *before* the underlying `_addView` runs, because NS's + // real `_addView` synchronously fires the `loaded` event + // chain on the new view — that's exactly where the user's + // `onLoaded` handler crashed. + const dialogFragment = { kind: 'DialogFragment' }; + const wrapper = new FakeView('wrapper'); + wrapper._dialogFragment = dialogFragment; + + // Install the "original" `_addView` spy FIRST so our wrap + // captures it via `bind()` and calls it as the inner. + // Reversing the order would let the test's spy clobber the + // wrap and assert nothing meaningful. + const pvc = new FakeView('pvc'); + let dialogFragmentAtAddTime: unknown = 'not-set'; + const baseAdd = pvc._addView!.bind(pvc); + pvc._addView = (view: ModalHostView, atIndex?: number) => { + dialogFragmentAtAddTime = (view as FakeView)._dialogFragment; + baseAdd(view, atIndex); + }; + + installPvcModalHostPropPropagation(pvc, wrapper); + + const newTemplateRoot = new FakeView('new-template-root'); + pvc._addView!(newTemplateRoot); + + expect(dialogFragmentAtAddTime).toBe(dialogFragment); + expect(newTemplateRoot._dialogFragment).toBe(dialogFragment); + }); + + it('marks the host with `PVC_ADD_VIEW_WRAPPED_MARKER` and is idempotent on repeat install', () => { + // Re-wrapping would create an O(n) chain of wrappers and + // make the failure mode at close ("slow no-op") progressively + // slower. The marker is the only signal we have to avoid this + // since the host instance has no public install-state API. + const wrapper = new FakeView('wrapper'); + wrapper._dialogFragment = { kind: 'DialogFragment' }; + const pvc = new FakeView('pvc'); + const original = pvc._addView; + + installPvcModalHostPropPropagation(pvc, wrapper); + const onceWrapped = pvc._addView; + installPvcModalHostPropPropagation(pvc, wrapper); + const twiceAttempted = pvc._addView; + + expect(onceWrapped).not.toBe(original); + expect(twiceAttempted).toBe(onceWrapped); + expect((pvc as unknown as Record)[PVC_ADD_VIEW_WRAPPED_MARKER]).toBe(true); + }); + + it('preserves `_addView` return value and atIndex semantics so NS internals are not affected', () => { + // ViewBase._addView is decorated with @profile and has no + // return value, but ProxyViewContainer's overrides may, and + // we must thread arguments through verbatim so behavior is + // identical to the un-wrapped instance. + const wrapper = new FakeView('wrapper'); + wrapper._dialogFragment = { kind: 'DialogFragment' }; + const pvc = new FakeView('pvc'); + let observedAtIndex: number | undefined = -1; + pvc._addView = ((view: ModalHostView, atIndex?: number) => { + observedAtIndex = atIndex; + return `added:${(view as FakeView).name}` as unknown as void; + }) as AddViewHost['_addView']; + + installPvcModalHostPropPropagation(pvc, wrapper); + + const newChild = new FakeView('new'); + const result = pvc._addView!(newChild, 7); + + expect(observedAtIndex).toBe(7); + expect(result).toBe('added:new'); + }); + + it('no-ops gracefully when the host has no `_addView` (e.g. an element that is not a View)', () => { + // Defensive: `componentRef.location.nativeElement` is *almost + // always* a `ProxyViewContainer`, but a third-party portal + // outlet could plug in a plain object. The wrap must not + // explode in that case — leaving the host untouched is the + // right behavior because HMR re-render of a non-View host is + // not a real scenario anyway. + const wrapper = new FakeView('wrapper'); + wrapper._dialogFragment = { kind: 'DialogFragment' }; + const nonViewHost = { _dialogFragment: undefined } as unknown as AddViewHost; + + expect(() => installPvcModalHostPropPropagation(nonViewHost, wrapper)).not.toThrow(); + expect((nonViewHost as unknown as Record)[PVC_ADD_VIEW_WRAPPED_MARKER]).toBeUndefined(); + }); + + it('is safe with nullish inputs', () => { + expect(() => installPvcModalHostPropPropagation(undefined, undefined)).not.toThrow(); + expect(() => installPvcModalHostPropPropagation(new FakeView(), undefined)).not.toThrow(); + expect(() => installPvcModalHostPropPropagation(undefined, new FakeView())).not.toThrow(); + }); + + it('re-reads wrapper host props on each `_addView` call so a re-render after the wrapper was re-stamped sees fresh values', () => { + // Simulates: dialog opens → wrapper._dialogFragment = A, + // wrap installed. Later, NS internals re-stamp the wrapper + // (e.g. DialogFragment was recreated after app suspend — see + // `_showNativeModalView`'s "Set owner._dialogFragment to + // this in case the DialogFragment was recreated after app + // suspend" branch). The wrap must mirror the *current* + // wrapper value at re-render time, not the stale A. + const wrapper = new FakeView('wrapper'); + wrapper._dialogFragment = { kind: 'A' }; + const pvc = new FakeView('pvc'); + installPvcModalHostPropPropagation(pvc, wrapper); + + // Wrapper restamp simulating the post-suspend reassign. + const fresh = { kind: 'B' }; + wrapper._dialogFragment = fresh; + + const newChild = new FakeView('new'); + pvc._addView!(newChild); + + expect(newChild._dialogFragment).toBe(fresh); + }); + }); +}); diff --git a/packages/angular/src/lib/cdk/dialog/modal-host-props.ts b/packages/angular/src/lib/cdk/dialog/modal-host-props.ts new file mode 100644 index 00000000..e1ff6a37 --- /dev/null +++ b/packages/angular/src/lib/cdk/dialog/modal-host-props.ts @@ -0,0 +1,141 @@ +/** + * Pure helpers that mirror NativeScript's modal-host properties + * (`_dialogFragment` on Android, `viewController` on iOS) from the + * ContentView wrapper that `attachComponentPortal` presents as the + * modal down onto every native-like descendant. + * + * Why? `parentView.showModal(targetView, ...)` stamps those props on + * `targetView` itself — but user template code reads them off the + * rendered template root that lives *inside* the wrapper (e.g. + * `onLoaded($event)` → `args.object._dialogFragment.getDialog()`). + * Without mirroring, every fresh modal open + every HMR + * `ɵɵreplaceMetadata` re-render hands the user `undefined`, which is + * how we hit: + * + * Cannot read properties of undefined (reading 'getDialog') + * at CheckinComponent.onLoaded + * + * The helpers live in a standalone module on purpose, mirroring + * `dialog-hmr-animation.ts`: they don't pull `@angular/core` or + * `@nativescript/core`, so they're trivially unit-testable in the + * Jest Node runner without an ESM transform or a NativeScript + * runtime stub. + */ + +/** + * Structural shape of a NativeScript View used by these helpers. + * Real `View` instances from `@nativescript/core` satisfy this + * trivially; tests pass plain objects with the same shape. + * + * Only the two host props we mirror and the `eachChildView` walk + * matter here — keeping the shape tight makes the helpers easy to + * reason about and prevents leaking unrelated `View` semantics. + */ +export interface ModalHostView { + _dialogFragment?: unknown; + viewController?: unknown; + eachChildView?: (callback: (child: ModalHostView) => boolean) => void; +} + +/** + * Structural shape required to wrap a NativeScript View's `_addView`. + * `ProxyViewContainer` (the host of an Angular component) satisfies + * this via `ViewBase._addView`. + */ +export interface AddViewHost extends ModalHostView { + _addView?: (view: ModalHostView, atIndex?: number) => void; +} + +/** + * Marker property used to make {@link installPvcModalHostPropPropagation} + * idempotent. Exported only so the spec can assert install + * idempotency without re-declaring the constant. + */ +export const PVC_ADD_VIEW_WRAPPED_MARKER = '__ng_modal_propagate_addview__'; + +/** + * Mirror `wrapper`'s modal-host props onto every descendant of + * `root` via NativeScript's logical `eachChildView` walk. + * + * - The wrapper itself is **never** written to. NS core owns that + * assignment and we must not shadow the canonical reference. + * - Writes are idempotent: a descendant that already holds the same + * reference is skipped, so repeat calls (HMR re-renders, multiple + * PVC adds in one render pass) stay cheap. + * - No-op when the modal isn't shown yet, has already closed, or + * when either input is missing. NS clears both props to null on + * close, so propagating after that point would only persist stale + * references on the descendants. + */ +export function propagateModalHostPropsToDescendants(wrapper: ModalHostView | undefined | null, root: ModalHostView | undefined | null): void { + if (!wrapper || !root) { + return; + } + const dialogFragment = wrapper._dialogFragment; + const viewController = wrapper.viewController; + if (dialogFragment == null && viewController == null) { + return; + } + + const visit = (view: ModalHostView | undefined): void => { + if (!view) { + return; + } + if (view !== wrapper) { + if (dialogFragment !== undefined && view._dialogFragment !== dialogFragment) { + view._dialogFragment = dialogFragment; + } + if (viewController !== undefined && view.viewController !== viewController) { + view.viewController = viewController; + } + } + view.eachChildView?.((child) => { + visit(child); + return true; + }); + }; + visit(root); +} + +/** + * Idempotently wrap `host._addView` so every child added after + * install — typically the new template root produced by Angular's + * `ɵɵreplaceMetadata` HMR cycle — has `wrapper`'s modal-host props + * mirrored onto it (and its current subtree) **before** NS attaches + * the view. + * + * Why "before"? `_addView` on a loaded parent synchronously calls + * `child.callLoaded()` deep inside, which fires the `loaded` event + * chain on the new template root. User code (e.g. + * `onLoaded($event)` → `args.object._dialogFragment.getDialog()`) + * runs from inside that synchronous call. The props **must** be + * present on the child by the time `_addView` runs — pre-hook + * position is the only place that guarantees this without + * monkey-patching NS internals. + * + * The wrap reads `wrapper._dialogFragment` / `wrapper.viewController` + * lazily (per call) via {@link propagateModalHostPropsToDescendants}, + * so a wrap installed while the modal is open keeps doing the right + * thing if HMR re-render happens later, and gracefully no-ops after + * the modal closes (both props become null on the wrapper). + * + * No-op when `host` lacks `_addView` (e.g. a non-View element) or + * when the wrap is already installed (`PVC_ADD_VIEW_WRAPPED_MARKER` + * sentinel). The wrap is intentionally NOT removable — the host + * lives only as long as the modal, so the wrap is GC'd with it. + */ +export function installPvcModalHostPropPropagation(host: AddViewHost | undefined | null, wrapper: ModalHostView | undefined | null): void { + if (!host || !wrapper) { + return; + } + const target = host as AddViewHost & Record; + if (target[PVC_ADD_VIEW_WRAPPED_MARKER] || typeof target._addView !== 'function') { + return; + } + const origAddView = target._addView.bind(target); + target._addView = (view: ModalHostView, atIndex?: number) => { + propagateModalHostPropsToDescendants(wrapper, view); + return origAddView(view, atIndex); + }; + target[PVC_ADD_VIEW_WRAPPED_MARKER] = true; +} diff --git a/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts b/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts index f823f522..85f91895 100644 --- a/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts +++ b/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts @@ -10,6 +10,7 @@ import { DetachedLoader } from '../detached-loader'; import { ComponentPortal, TemplatePortal } from '../portal/common'; import { NativeScriptDomPortalOutlet } from '../portal/nsdom-portal-outlet'; import { NativeDialogConfig } from './dialog-config'; +import { AddViewHost, installPvcModalHostPropPropagation, ModalHostView, propagateModalHostPropsToDescendants } from './modal-host-props'; export class NativeModalRef { _id: string; @@ -177,6 +178,27 @@ export class NativeModalRef { if (!didModalOpen(this.parentView, modalView)) { this._handleFailedOpen(); } + + // After `showModal`, NativeScript core has stamped + // `_dialogFragment` (Android) / `viewController` (iOS) on + // `targetView` itself — but user template code (`onLoaded($event)` + // → `args.object._dialogFragment.getDialog()...`) reads those + // props on the rendered template root, which is a *descendant* + // of `targetView`, not `targetView` itself. Mirror them down so + // the template root (and any nested loaded handler) sees the + // real host objects instead of `undefined`, then install a + // lazy wrap on the host PVC's `_addView` so future child + // additions — typically the new template root produced by + // Angular's `ɵɵreplaceMetadata` HMR cycle — get the same + // props mirrored *before* NS attaches the view and fires + // its `loaded` event chain. See `modal-host-props.ts` for the + // full rationale. + propagateModalHostPropsToDescendants(targetView as ModalHostView, targetView as ModalHostView); + const hostView = componentRef.location?.nativeElement as View | undefined; + if (hostView) { + installPvcModalHostPropPropagation(hostView as unknown as AddViewHost, targetView as ModalHostView); + } + return componentRef; } @@ -200,6 +222,7 @@ export class NativeModalRef { dispose() { this.portalOutlet.dispose(); } + private startModalNavigation() { const frame = this.parentView instanceof Frame ? this.parentView : this.parentView?.page?.frame || Frame.topmost(); diff --git a/packages/angular/src/lib/legacy/router/hmr-route-replay.spec.ts b/packages/angular/src/lib/legacy/router/hmr-route-replay.spec.ts index a890d066..aed8f532 100644 --- a/packages/angular/src/lib/legacy/router/hmr-route-replay.spec.ts +++ b/packages/angular/src/lib/legacy/router/hmr-route-replay.spec.ts @@ -118,24 +118,26 @@ describe('NativeScriptAngularHmrRouteReplay', () => { endAngularHmrRouteRestore(); }); - it('keeps the restoring window open during the grace period after a multi-URL replay completes', async () => { - pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + it('keeps the restoring window open during the grace period after the deferred named-outlet replay completes', async () => { + // Latest URL has named outlets → start-path resolver falls back to '/' + // and emits a single forward navigation. No back-stack walk. pushAngularHmrRouteHistoryEntry('/profile'); + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); snapshotAngularHmrRouteHistory(); - expect(readAngularHmrPendingStartPath()).toBe('/talk/(todayTab:today)'); - expect(readAngularHmrPendingForwardNavigations()).toEqual(['/profile']); + expect(readAngularHmrPendingStartPath()).toBe('/'); + expect(readAngularHmrPendingForwardNavigations()).toEqual(['/talk/(todayTab:today)']); expect(isAngularHmrRestoringRoute()).toBe(true); const router = createRouterMock(); const replay = new NativeScriptAngularHmrRouteReplay(router as any); - router.emitNavigationEnd('/talk/(todayTab:today)'); + router.emitNavigationEnd('/'); await flushMicrotasks(); await flushMicrotasks(); - expect(router.navigateByUrl).toHaveBeenCalledWith('/profile'); + expect(router.navigateByUrl).toHaveBeenCalledWith('/talk/(todayTab:today)'); expect(readAngularHmrPendingRouteHistory()).toEqual([]); // The replay finished but the grace period should still consider the // window open so async (loaded) handlers can suppress default @@ -151,32 +153,31 @@ describe('NativeScriptAngularHmrRouteReplay', () => { replay.ngOnDestroy(); }); - it('keeps the window open across the grace period when the replay aborts mid-stack', async () => { - pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + it('keeps the window open across the grace period when the deferred named-outlet replay aborts', async () => { pushAngularHmrRouteHistoryEntry('/profile'); pushAngularHmrRouteHistoryEntry('/profile/edit'); + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); snapshotAngularHmrRouteHistory(); - // `readAngularHmrPendingStartPath` is what opens the window in the - // real bootstrap flow (it's called from the START_PATH provider). - // The test mirrors that so the replay service has a window to keep - // open during the grace period. - expect(readAngularHmrPendingStartPath()).toBe('/talk/(todayTab:today)'); - expect(readAngularHmrPendingForwardNavigations()).toEqual(['/profile', '/profile/edit']); + // Only the latest URL (with named outlets) is deferred to the forward + // walk. Intermediate URLs are NOT re-navigated to under the clean-DX + // policy (NS Frames own the page stack, not the URL serializer). + expect(readAngularHmrPendingStartPath()).toBe('/'); + expect(readAngularHmrPendingForwardNavigations()).toEqual(['/talk/(todayTab:today)']); expect(isAngularHmrRestoringRoute()).toBe(true); const router = createRouterMock(); - router.navigateByUrl.mockImplementation((url: string) => Promise.resolve(url === '/profile')); + // Simulate the single forward navigation rejecting (e.g. route guard). + router.navigateByUrl.mockImplementation(() => Promise.resolve(false)); const replay = new NativeScriptAngularHmrRouteReplay(router as any); - router.emitNavigationEnd('/talk/(todayTab:today)'); + router.emitNavigationEnd('/'); await flushMicrotasks(); await flushMicrotasks(); await flushMicrotasks(); - expect(router.navigateByUrl).toHaveBeenCalledWith('/profile'); - expect(router.navigateByUrl).toHaveBeenCalledWith('/profile/edit'); + expect(router.navigateByUrl).toHaveBeenCalledWith('/talk/(todayTab:today)'); expect(readAngularHmrPendingRouteHistory()).toEqual([]); // Even when aborted, the grace period should still hold the window // open so user-app guards see `true` until the deferred close fires. @@ -209,17 +210,19 @@ describe('NativeScriptAngularHmrRouteReplay', () => { }); it('clears the deferred close timer when the service is destroyed', async () => { - pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + // Latest URL has named outlets → start-path resolver falls back to '/' + // and emits a single forward navigation. pushAngularHmrRouteHistoryEntry('/profile'); + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); snapshotAngularHmrRouteHistory(); - expect(readAngularHmrPendingStartPath()).toBe('/talk/(todayTab:today)'); + expect(readAngularHmrPendingStartPath()).toBe('/'); expect(isAngularHmrRestoringRoute()).toBe(true); const router = createRouterMock(); const replay = new NativeScriptAngularHmrRouteReplay(router as any); - router.emitNavigationEnd('/talk/(todayTab:today)'); + router.emitNavigationEnd('/'); await flushMicrotasks(); await flushMicrotasks(); @@ -239,14 +242,14 @@ describe('NativeScriptAngularHmrRouteReplay', () => { }); it('closes the window immediately when the initial navigation fails (no grace period)', () => { - pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); pushAngularHmrRouteHistoryEntry('/profile'); + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); snapshotAngularHmrRouteHistory(); const router = createRouterMock(); const replay = new NativeScriptAngularHmrRouteReplay(router as any); - router.emitNavigationCancel('/talk/(todayTab:today)'); + router.emitNavigationCancel('/'); expect(router.navigateByUrl).not.toHaveBeenCalled(); expect(readAngularHmrPendingRouteHistory()).toEqual([]); diff --git a/packages/angular/src/lib/legacy/router/hmr-route-replay.ts b/packages/angular/src/lib/legacy/router/hmr-route-replay.ts index 0ec08b13..0495b779 100644 --- a/packages/angular/src/lib/legacy/router/hmr-route-replay.ts +++ b/packages/angular/src/lib/legacy/router/hmr-route-replay.ts @@ -32,14 +32,18 @@ import { const REPLAY_COMPLETED_GRACE_MS = 1000; /** - * Replays the back-stack snapshot captured by `NativeScriptAngularHmrRouteTracker` - * during HMR. The router's initial navigation already lands on the bottom of - * the stack (`stack[0]`); this service walks `stack[1..n]` so the user keeps - * back navigation across HMR cycles. + * Restores the user's CURRENT URL after an HMR reboot. * - * The replay is single-shot per bootstrap. Any failure (cancelled navigation, - * unrouteable URL) aborts the rest of the replay so we don't fight the router - * — the user keeps whichever subset of the stack we successfully re-pushed. + * HMR-DX policy: at most one post-bootstrap navigation. The framework no + * longer walks the captured back-stack (`stack[1..n]`) — NativeScript Frames + * own the page stack, not the URL serializer, so the URL walk never rebuilt + * the Frame stack anyway, it only created visible mid-save re-navigation + * sequences (especially with tab-based named outlets) that the user had to + * sit through. Now `readAngularHmrPendingForwardNavigations()` returns at + * most one URL — the deferred named-outlet case — and we replay only that. + * + * Any failure (cancelled navigation, unrouteable URL) closes the restoring + * window with `replay-aborted` so default navigations can resume. */ @Injectable() export class NativeScriptAngularHmrRouteReplay implements OnDestroy { diff --git a/packages/angular/src/lib/legacy/router/hmr-route-state-core.ts b/packages/angular/src/lib/legacy/router/hmr-route-state-core.ts index 0a1df3fe..2b0d2687 100644 --- a/packages/angular/src/lib/legacy/router/hmr-route-state-core.ts +++ b/packages/angular/src/lib/legacy/router/hmr-route-state-core.ts @@ -93,30 +93,67 @@ export function captureAngularHmrPendingStartPath(value: unknown, source = 'hmr- return writeAngularHmrRouteState(value, { pending: true, source }); } +/** + * Match Angular Router's named-outlet syntax in a serialized URL. + * + * The router emits named outlets as `(outletName:segments[//otherName:segments])` + * — see `DefaultUrlSerializer`. Any URL the captures match `/\(\w+:` + * contains at least one named-outlet segment. + * + * Used to gate the start-path deferral below: when a captured URL has named + * outlets it CANNOT be used as the initial-navigation path on the next boot + * (the outlet directives are inside child components that don't exist yet at + * `router.initialNavigation()` time, so `PageRouterOutlet.activateWith` + * returns early with "No outlet found relative to activated route" and the + * app renders a white screen). + */ +function hasNamedOutletsInUrl(url: string): boolean { + if (typeof url !== 'string') { + return false; + } + return /\([A-Za-z0-9_-]+:/.test(url); +} + export function readAngularHmrPendingStartPath(): string { - // When a back-stack snapshot exists we boot to the bottom of the stack and - // let `replayAngularHmrPendingForwardNavigations` walk the rest. Otherwise - // fall back to the legacy single-URL slot so projects without history - // tracking still land on the page they were viewing. + // HMR-DX policy: restore only the user's CURRENT URL. NativeScript Frames + // own the back-stack, not the URL, so walking captured history URLs forward + // doesn't reconstruct the page stack — it just causes visible re-navigation + // sequences (especially with tab-based named outlets) that the user has to + // sit through after every save. We pick the last captured URL as the + // restoration target and bail on the history walk entirely. The + // `forward-navigations` reader returns at most one URL (the deferred + // named-outlet case), so the replay service performs zero or one + // post-bootstrap navigation, not N. const pendingHistory = readHistoryArray(PENDING_HISTORY_KEY); if (pendingHistory.length > 0) { - // Open the restoring-route window so user-app default navigations - // can step out of the framework's way until replay completes. The - // forward-navigation walk in `NativeScriptAngularHmrRouteReplay` - // closes the window after the final URL lands or fails. We pass - // the deepest captured URL so consumers can compare against the - // active router URL if they want fine-grained suppression. - beginAngularHmrRouteRestore(pendingHistory[pendingHistory.length - 1]); - return pendingHistory[0]; + const target = pendingHistory[pendingHistory.length - 1]; + beginAngularHmrRouteRestore(target); + // Named-outlet URLs cannot be served as the initial navigation URL. + // The outlet directives (e.g. ``) + // live inside child components that only render AFTER the primary + // outlet activates. On `router.initialNavigation()` with a URL like + // `/(listenNowTab:listen-now)`, Angular tries to activate `listenNowTab` + // immediately and `PageRouterOutlet.activateWith` returns early because + // no outlet is registered yet — white screen. Defer to a single forward + // navigation that fires AFTER the first NavigationEnd, by which time the + // outlet directives have registered. + if (hasNamedOutletsInUrl(target)) { + return '/'; + } + return target; } const g = getGlobalState(); const fallback = normalizeAngularHmrRouteUrl(g[PENDING_START_PATH_KEY]?.url ?? g[PENDING_START_PATH_KEY]) || ''; if (fallback) { - // Single-URL fallback path: user-app code should still suppress - // default navigations briefly — the new bootstrap is about to - // navigate to `fallback`, so a default tab init that fires first - // would still stomp it. + // Same deferral as above for the legacy single-URL slot. + if (hasNamedOutletsInUrl(fallback)) { + beginAngularHmrRouteRestore(fallback); + // Stash the deferred URL so the forward-navigations reader picks it + // up after the initial '/' navigation lands. + writeHistoryArray(PENDING_HISTORY_KEY, [fallback]); + return '/'; + } beginAngularHmrRouteRestore(fallback); } return fallback; @@ -285,15 +322,29 @@ export function readAngularHmrPendingRouteHistory(): string[] { /** * Read URLs to navigate forward through after the initial navigation finishes. - * The first entry of the stack is the `START_PATH` consumed by the router; the - * rest are forward navigations to push onto the new back-stack. + * + * HMR-DX policy: at most one post-bootstrap navigation. We only need a forward + * navigation when `readAngularHmrPendingStartPath` had to return '/' because + * the user's current URL contains named outlets (the outlet directives don't + * exist yet at initial-navigation time, so we defer to a single nav after the + * primary outlet has registered them). For URLs with no named outlets the + * start path IS the user's current URL and no forward step is needed. + * + * We intentionally do NOT walk the captured back-stack of intermediate URLs — + * NativeScript Frames own the page stack, not the URL serializer, so URL + * replay never reconstructs the Frame stack anyway and only creates visible + * mid-save re-navigations. */ export function readAngularHmrPendingForwardNavigations(): string[] { const pending = readHistoryArray(PENDING_HISTORY_KEY); - if (pending.length <= 1) { + if (pending.length === 0) { return []; } - return pending.slice(1); + const target = pending[pending.length - 1]; + if (hasNamedOutletsInUrl(target)) { + return [target]; + } + return []; } /** diff --git a/packages/angular/src/lib/legacy/router/hmr-route-state.spec.ts b/packages/angular/src/lib/legacy/router/hmr-route-state.spec.ts index 6948413c..28cf1486 100644 --- a/packages/angular/src/lib/legacy/router/hmr-route-state.spec.ts +++ b/packages/angular/src/lib/legacy/router/hmr-route-state.spec.ts @@ -172,13 +172,33 @@ describe('Angular HMR route state', () => { expect(readAngularHmrRouteHistory()).toEqual([]); }); - it('exposes everything but the bottom of the stack as forward navigations', () => { + it('returns no forward navigations when the latest URL has no named outlets (clean DX policy)', () => { + // HMR DX policy: restore only the user's current URL. Walking captured + // back-stack URLs creates visible mid-save re-navigation sequences that + // the user has to sit through, AND it never reconstructs the Frame back + // stack (NS Frames own the page stack, not the URL serializer). Forward + // is empty when the start path itself IS the user's current URL. pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); pushAngularHmrRouteHistoryEntry('/profile'); pushAngularHmrRouteHistoryEntry('/profile/edit'); snapshotAngularHmrRouteHistory(); - expect(readAngularHmrPendingForwardNavigations()).toEqual(['/profile', '/profile/edit']); + expect(readAngularHmrPendingForwardNavigations()).toEqual([]); + }); + + it('returns the deferred named-outlet URL as the single forward navigation when the latest URL has named outlets', () => { + // Named-outlet URLs can't be served as the initial-navigation path + // (outlet directives live in lazy child templates that don't exist + // yet at `router.initialNavigation()` time). Start path falls back + // to '/' and a single forward navigation lands the user back on the + // captured deep URL after the primary outlet has registered the + // named outlets. + pushAngularHmrRouteHistoryEntry('/profile'); + pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); + snapshotAngularHmrRouteHistory(); + + expect(readAngularHmrPendingStartPath()).toBe('/'); + expect(readAngularHmrPendingForwardNavigations()).toEqual(['/talk/(todayTab:today)']); }); it('returns an empty forward list when the snapshot has only the bottom of the stack', () => { @@ -188,12 +208,14 @@ describe('Angular HMR route state', () => { expect(readAngularHmrPendingForwardNavigations()).toEqual([]); }); - it('uses the bottom of the snapshot as the pending start path so the router boots there first', () => { + it('uses the TOP of the snapshot as the pending start path so the router lands directly on the user current URL', () => { pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); pushAngularHmrRouteHistoryEntry('/profile'); snapshotAngularHmrRouteHistory(); - expect(readAngularHmrPendingStartPath()).toBe('/talk/(todayTab:today)'); + // Latest captured URL is '/profile' (no named outlets) → that's the + // direct start path. No walk through intermediate '/talk/...'. + expect(readAngularHmrPendingStartPath()).toBe('/profile'); }); it('falls back to the legacy single-URL slot when no snapshot is present', () => { @@ -233,15 +255,15 @@ describe('Angular HMR route state', () => { }); it('opens the window when the pending route history snapshot resolves a deep route', () => { + // Clean-DX policy: start path is the user's CURRENT URL (top of stack), + // not the bottom. The restoration window opens against the same URL + // since that's where we're heading. pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)'); pushAngularHmrRouteHistoryEntry('/profile'); snapshotAngularHmrRouteHistory(); - expect(readAngularHmrPendingStartPath()).toBe('/talk/(todayTab:today)'); + expect(readAngularHmrPendingStartPath()).toBe('/profile'); expect(isAngularHmrRestoringRoute()).toBe(true); - // The window is opened with the deepest captured URL so user-app - // code can decide what to do based on where the framework is - // ultimately heading, not just the bottom of the stack. expect(getAngularHmrRestoringRoute()).toBe('/profile'); }); From dfd63b06acc802a1f7e6e4533093dccb4d523192 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Tue, 9 Jun 2026 11:38:13 -0700 Subject: [PATCH 16/19] fix(hmr): emulated styleUrl handling --- .../angular/src/lib/nativescript-renderer.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/packages/angular/src/lib/nativescript-renderer.ts b/packages/angular/src/lib/nativescript-renderer.ts index 03ef3af8..0420fe81 100644 --- a/packages/angular/src/lib/nativescript-renderer.ts +++ b/packages/angular/src/lib/nativescript-renderer.ts @@ -107,6 +107,12 @@ function modifiesDom() { export class NativeScriptRendererFactory implements RendererFactory2 { private componentRenderers = new Map(); + // Signature of the styles last applied for each component `type.id`. Used to + // detect a `styleUrls`/`styles` change across an `replaceMetadata` HMR + // update so the cached renderer can re-apply the new (scoped) styles - the + // renderer cache below otherwise short-circuits `addStyles`, so a component + // style edit would never take effect without a full re-bootstrap. + private componentStyleSignatures = new Map(); private defaultRenderer: Renderer2; // backwards compatibility with RadListView private rootView = inject(APP_ROOT_VIEW); @@ -152,6 +158,25 @@ export class NativeScriptRendererFactory implements RendererFactory2 { renderer.applyToHost(hostElement); } + // HMR: a component `styleUrls`/`styles` edit recompiles the component + // metadata and `replaceMetadata` recreates its views, which re-enters + // `createRenderer` with the SAME `type.id` but NEW `type.styles`. The + // cache hit above would otherwise return the renderer whose one-time + // `addStyles` already ran with the OLD styles, so the change would never + // render. When the style signature changed, re-apply: emulated styles + // are re-scoped + re-added (same selector/specificity -> later wins); + // None-encapsulation styles are re-added globally. Both keep the shared + // `rootModuleID` tag so module teardown still removes them. + const styleSignature = this.styleSignature(type.styles); + if (this.componentStyleSignatures.get(type.id) !== styleSignature) { + this.componentStyleSignatures.set(type.id, styleSignature); + if (renderer instanceof EmulatedRenderer) { + renderer.reapplyStyles(type.styles); + } else { + this.reapplyGlobalStyles(type.styles); + } + } + return renderer; } @@ -166,8 +191,31 @@ export class NativeScriptRendererFactory implements RendererFactory2 { } this.componentRenderers.set(type.id, renderer); + this.componentStyleSignatures.set(type.id, this.styleSignature(type.styles)); return renderer; } + + // Stable signature of a component's styles, used to detect HMR style edits. + private styleSignature(styles: (string | any[])[]): string { + try { + return (styles || []).map((s) => s.toString()).join("\n"); + } catch { + return ''; + } + } + + // Re-apply ViewEncapsulation.None component styles (global, unscoped) on an + // HMR style edit and re-trigger styling on the live view tree. + private reapplyGlobalStyles(styles: (string | any[])[]): void { + try { + styles.map((s) => s.toString()).forEach((v) => addStyleToCss(v, this.rootModuleID)); + Application.getRootView()?._onCssStateChange(); + } catch (err) { + if (NativeScriptDebug.enabled) { + NativeScriptDebug.rendererLog(`reapplyGlobalStyles failed: ${err}`); + } + } + } begin() { if (__APPLE__ && this.wrapCdInTransaction) { if (this.cdDepth > 0) { @@ -487,17 +535,40 @@ const addScopedStyleToCss = profile( export class EmulatedRenderer extends NativeScriptRenderer { private contentAttr: string; private hostAttr: string; + private componentId: string; private rootModuleId = inject(NATIVESCRIPT_ROOT_MODULE_ID); constructor(component: RendererType2, rootView: View) { super(rootView); const componentId = component.id.replace(ATTR_SANITIZER, '_'); + this.componentId = componentId; this.contentAttr = replaceNgAttribute(CONTENT_ATTR, componentId); this.hostAttr = replaceNgAttribute(HOST_ATTR, componentId); this.addStyles(component.styles, componentId); } + /** + * Re-apply this component's emulated-scoped styles after an HMR + * `styleUrls`/`styles` edit. The renderer is cached per component type id + * (see `NativeScriptRendererFactory.createRenderer`), so the constructor's + * one-time `addStyles` never re-runs on `replaceMetadata` - without this + * the new styles never reach the device. The freshly-compiled rules are + * re-scoped to this renderer's component id (so the existing views, which + * carry that `_ngcontent` attribute, match) and re-added under the same + * `rootModuleId` tag; since they share the previous rules' selector and + * specificity, the later-added values win. We then re-trigger styling on + * the live view tree so the change paints without a re-bootstrap. + */ + reapplyStyles(styles: (string | any[])[]): void { + this.addStyles(styles, this.componentId); + try { + Application.getRootView()?._onCssStateChange(); + } catch { + // best-effort restyle; never let an HMR style re-apply throw + } + } + applyToHost(view: NgView) { super.setAttribute(view, this.hostAttr, ''); } From af7234eb8070cad38d4e9e8a6a58dbb1e1825a6e Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 14 Jun 2026 10:04:18 -0700 Subject: [PATCH 17/19] fix: embedded first launch --- packages/angular/src/lib/application.ts | 27 ++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/angular/src/lib/application.ts b/packages/angular/src/lib/application.ts index 035d7b13..e2e40365 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -835,12 +835,29 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { } }; + // First (cold) launch entry point. Embedded apps are already running inside a + // host UIApplication, so the native app loop must NOT be (re)started via + // Application.run() with no entry — that routes through runAsEmbeddedApp() -> + // createRootView() before Angular has bootstrapped a root, and since the + // launch listener is intentionally not registered in embedded mode, it throws + // "Main entry is missing. App cannot be started." Instead bootstrap Angular + // directly; setRootView() then calls Application.run({ create }) with a real + // entry once the root view exists. Non-embedded apps still need + // Application.run() to drive UIApplicationMain/launch handling. + const runFirstLaunch = () => { + if (currentOptions.embedded) { + bootstrapRoot('applaunch'); + } else { + Application.run(); + } + }; + if (isWebpackHot) { // Webpack-specific HMR handling import.meta['webpackHot'].decline(); if (!Application.hasLaunched()) { - Application.run(); + runFirstLaunch(); return; } bootstrapRoot('hotreload'); @@ -853,16 +870,12 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { // which will call __reboot_ng_modules__ when needed if (!Application.hasLaunched()) { - Application.run(); + runFirstLaunch(); return; } bootstrapRoot('hotreload'); return; } - if (currentOptions.embedded) { - bootstrapRoot('applaunch'); - } else { - Application.run(); - } + runFirstLaunch(); } From f209a486fee60c9b2bbbe7666c90d3cca8261b3a Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Tue, 16 Jun 2026 11:43:17 -0700 Subject: [PATCH 18/19] chore: improve log warnings during hmr sessions with begign NG0912 --- packages/angular/src/lib/application.ts | 73 ++++++++++++------------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/packages/angular/src/lib/application.ts b/packages/angular/src/lib/application.ts index e2e40365..b151f43b 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -62,6 +62,39 @@ angularHmrGlobal.__NS_REMEMBER_ANGULAR_CORE__ = (core: any) => { setAngularCoreForHmr(core, angularHmrGlobal); }; +/** + * Suppress benign NG0912 ("Component ID generation collision") warnings in + * dev/HMR. They fire when the same logical component is re-defined under a + * second class identity (a `.ts` edit, or a module evaluated under two + * dev-server URLs) — same name, no real collision. We only suppress when both + * class names in the message match, so genuine collisions still surface. + * + * Installed at module load, not inside `runNativeScriptAngularApp`: that runs + * after `main.ts`'s imports — including the user's components — have already + * defined, so a filter there misses the cold-boot warnings. Same import-order + * guarantee `installAngularHmrComponentRegistrar()` relies on. Idempotent. + */ +function installAngularNg0912HmrWarningFilter(): void { + const w: { __NS_ANGULAR_NG0912_FILTER_INSTALLED__?: boolean } = globalThis as any; + if (w.__NS_ANGULAR_NG0912_FILTER_INSTALLED__) return; + w.__NS_ANGULAR_NG0912_FILTER_INSTALLED__ = true; + const origWarn = console.warn.bind(console); + // Captures the two class names from "Components 'Foo' and 'Bar' with selector …". + const NG0912_NAME_MATCH = /NG0912[\s\S]*?Components '([^']+)' and '([^']+)' with selector/; + console.warn = (...args: any[]) => { + const msg = String(args[0] ?? ''); + if (msg.includes('NG0912')) { + const m = NG0912_NAME_MATCH.exec(msg); + if (m && m[1] === m[2]) { + return; + } + } + origWarn(...args); + }; +} +// Install before any user component module evaluates its `ɵɵdefineComponent`. +installAngularNg0912HmrWarningFilter(); + export interface AppLaunchView extends LayoutBase { // called when the animation is to begin startAnimation?: () => void; @@ -766,44 +799,8 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { resetAngularHmrCompiledComponents(getAngularCoreForHmrReset(AngularCore as any, globalThis as any)); }; - // Suppress benign HMR-induced NG0912 ("Component ID generation collision") - // warnings. On a `.ts` edit Angular Live Reload's - // `ListenNowComponent_UpdateMetadata` function in the freshly re-imported - // module calls `ɵɵreplaceMetadata` → `ɵɵdefineComponent` → `getComponentId` - // against a class that shares its name with the just-rebooted instance but - // not its identity (one comes from the route-loaded module, the other from - // the dynamically-fetched `/@ng/component?c=…` metadata chunk). The check - // surfaces every component the user touches as a noisy warning even though - // there's no real collision — same logical class, two transient identities - // during the HMR cycle. - // - // Real collisions — different classes that happen to hash to the same id — - // produce a warning where `'X' and 'Y'` (different class names) appear in - // the message. We only filter when both names match, so genuine duplicates - // still reach the user. - // - // Install once per process; the filter self-detaches if the warning text - // changes shape (defensive against future Angular wording tweaks). - (() => { - const w: { __NS_ANGULAR_NG0912_FILTER_INSTALLED__?: boolean } = global as any; - if (w.__NS_ANGULAR_NG0912_FILTER_INSTALLED__) return; - w.__NS_ANGULAR_NG0912_FILTER_INSTALLED__ = true; - const origWarn = console.warn.bind(console); - // Pattern: "Components 'Foo' and 'Bar' with selector 'xyz'" — capture - // both class names and compare. We suppress only when they're identical - // (the HMR pseudo-collision signature). - const NG0912_NAME_MATCH = /NG0912[\s\S]*?Components '([^']+)' and '([^']+)' with selector/; - console.warn = (...args: any[]) => { - const msg = String(args[0] ?? ''); - if (msg.includes('NG0912')) { - const m = NG0912_NAME_MATCH.exec(msg); - if (m && m[1] === m[2]) { - return; - } - } - origWarn(...args); - }; - })(); + // Already installed at module load; this is a no-op safety net. + installAngularNg0912HmrWarningFilter(); global['__reboot_ng_modules__'] = (shouldDisposePlatform: boolean = false) => { // Bump the global HMR cycle counter so subsequent diagnostic log // lines (class registry, dialog services) can be cross-referenced From 33c7337d8ac0645d4b52f44b7a7dd8195465778a Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 19 Aug 2026 11:52:32 -0700 Subject: [PATCH 19/19] chore: align branch with main tooling after rebase - bump ts-jest to 29.4.12 so jest specs compile under TypeScript 6 (29.4.9 forced moduleResolution=node10, which TS 6 rejects) - satisfy eslint 9 no-empty / no-empty-function / no-this-alias rules now enforced by main's flat config --- package-lock.json | 23 +++++++++++++++---- package.json | 2 +- packages/angular/src/lib/application.ts | 12 +++++++--- packages/angular/src/lib/hmr-cache-store.ts | 3 +++ .../lib/legacy/router/hmr-route-cache-core.ts | 8 +++++-- 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index f11af940..6f55200a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,7 @@ "sass": "1.72.0", "sinon": "^17.0.0", "tailwindcss": "~3.4.0", - "ts-jest": "29.4.9", + "ts-jest": "29.4.12", "ts-node": "10.9.2", "tslib": "^2.8.0", "typescript": "6.0.3", @@ -33748,9 +33748,9 @@ "license": "Apache-2.0" }, "node_modules/ts-jest": { - "version": "29.4.9", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", - "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", "dev": true, "license": "MIT", "dependencies": { @@ -33760,7 +33760,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.4", + "semver": "^7.8.5", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -33800,6 +33800,19 @@ } } }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ts-jest/node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", diff --git a/package.json b/package.json index e858b61b..308149fb 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "sass": "1.72.0", "sinon": "^17.0.0", "tailwindcss": "~3.4.0", - "ts-jest": "29.4.9", + "ts-jest": "29.4.12", "ts-node": "10.9.2", "tslib": "^2.8.0", "typescript": "6.0.3", diff --git a/packages/angular/src/lib/application.ts b/packages/angular/src/lib/application.ts index b151f43b..5af1d2cb 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -369,7 +369,9 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { NativeScriptDebug.hmrLog(`cleared Angular route caches before reboot: detachedViews=${clearedDetached} routeFields=${cleared} locationState=${JSON.stringify(clearedLocation)}`); } } - } catch {} + } catch { + // ignore + } }; const updatePlatformRef = (moduleRef: NgModuleRef | ApplicationRef, reason: NgModuleReason) => { const newPlatformRef = moduleRef.injector.get(PlatformRef); @@ -392,7 +394,9 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { try { (currentRoot as any)._onCssStateChange?.(); - } catch {} + } catch { + // ignore + } }, 0); }; const setRootView = (ref: NgModuleRef | ApplicationRef | View) => { @@ -815,7 +819,9 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { } try { global['__NS_CAPTURE_ANGULAR_HMR_ROUTE__']?.(); - } catch {} + } catch { + // ignore + } disposeLastModules('hotreload'); if (traceEnabled) { NativeScriptDebug.hmrLog(`after disposeLastModules cycle=${cycleNum} bootstrapId=${bootstrapId}`); diff --git a/packages/angular/src/lib/hmr-cache-store.ts b/packages/angular/src/lib/hmr-cache-store.ts index 2865e506..e8392e19 100644 --- a/packages/angular/src/lib/hmr-cache-store.ts +++ b/packages/angular/src/lib/hmr-cache-store.ts @@ -185,6 +185,7 @@ export class HmrCacheStore { typeof requested === 'number' && requested > 0 ? Math.floor(requested) : 0; + // eslint-disable-next-line @typescript-eslint/no-empty-function this._log = options.log ?? (() => {}); // Trim seed if it overshoots the configured ceiling — possible if // a previous session ran with a larger `maxEntries` than this one. @@ -267,6 +268,7 @@ export class HmrCacheStore { throw new Error('[HmrCacheStore] scope() requires a non-empty prefix'); } const fullPrefix = `${prefix}:`; + // eslint-disable-next-line @typescript-eslint/no-this-alias const parent = this; return { prefix: fullPrefix, @@ -356,6 +358,7 @@ export function createDefaultHmrCacheStore( const invalidateEventName = options.invalidateEventName ?? DEFAULT_INVALIDATE_EVENT; const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + // eslint-disable-next-line @typescript-eslint/no-empty-function const log = options.log ?? (() => {}); const hot = readImportMetaHot(); diff --git a/packages/angular/src/lib/legacy/router/hmr-route-cache-core.ts b/packages/angular/src/lib/legacy/router/hmr-route-cache-core.ts index b7bde7b2..14014ed1 100644 --- a/packages/angular/src/lib/legacy/router/hmr-route-cache-core.ts +++ b/packages/angular/src/lib/legacy/router/hmr-route-cache-core.ts @@ -18,7 +18,9 @@ function destroyRouteCacheValue(value: unknown): void { if (typeof destroy === 'function') { try { destroy.call(value); - } catch {} + } catch { + // ignore + } } } @@ -36,7 +38,9 @@ function clearRouteCacheField(route: Record, key: (typeof ROUTE } catch { try { route[key] = undefined; - } catch {} + } catch { + // ignore + } } return true;